/* © 2026 K2B NETWORK SOLUTIONS LLC. All rights reserved. */ /* eslint-disable */ // ServiceCore — repair-order logic that sits on top of DMSCore: // • service settings (card fee %, shop labor rates, recon warning %, invoice footer) // • standard jobs (templates) + applyStdJob (COPIES a template into an RO) // • RO jobs (= ro.ops) with per-job tech / pay type / pricing rule, job parts // • customer search (name / phone / email / Customer # — RC30 (cn): the 7-digit number; an old number still finds the account) + their vehicles // • RO notes (complaint / diagnosis / work performed / customer-facing / internal) // • RO payments: record / void / refund / change pay type, split-pay check // // ── RO data model (fields this module adds; legacy fields kept) ───────────── // ro.custNum, ro.cust {custNum,name,phone,email}, ro.customer ("LAST, FIRST") // ro.veh {vin,yr,mk,mdl,miles,stock}, ro.vehicle ("2022 CHEVROLET MALIBU"), ro.vin, ro.miles // ro.notes {complaint, diagnosis, workPerformed, customer (prints), internal (never prints)} // ro.ops[] = the RO's JOBS: {id, op (labor op code), desc, custDesc, hrs, rateMode "rate"|"flat", // rate, flat, lab (extended labor $), techId, payType, stdJobId?, status, approval, // complaint, cause, correction, techNotes (internal)} // ro.partsLines[] {id, part, desc, qty, cost, sale, jobId?} (job parts = lines with jobId) // ro.sublets[] {id, vendor, desc, cost, sale, qty?, jobId?, po} (outside work; sale → 466, cost 666 / 246) // Declined jobs (approval "declined") and their parts / sublets are excluded from totals, tax and GL. // All line ids are stable strings from DMS._uid ("OP-…", "PL-…", "SB-…"). No shop supplies / time punches yet. // ro.recon + ro.internalStock (+ ro.stock) are set on internal / recon ROs (ReconCore reads them). // ro.payments[] {id, method cash|card|invoice|warranty|contract|courtesy|internal, amount, fee, feePct, // at, by, receiptNo, ref, last4, tendered, change, drawerRef, account, acctName, dueDate, // provider, claimNo, approvedAmt, customerResp, stock, note, posted, jeId, postedSeparately, // voided, voidReason, voidedBy, voidedAt, voidJeId, refundOf} // // ── GL (see DMSCore roCloseLines for the close entry) ─────────────────────── // Close (ST-): CR labor by job pay type (460A/462/460B/463), CR parts 467, CR tax 324A, // DR each live payment to its payer account: cash 201 · card 202 (amount+fee) · invoice A/R 220 · // warranty 263 · service contract 264 · courtesy 609 · internal → unit inventory; // CR card fee income 905E; DR 660A / CR 999 labor cost; DR 667 / CR 242 parts cost. // Payment after close (PMT-): DR payer acct, CR A/R 220 (+ CR 905E fee on card). // Void of a posted payment (VD-): DR A/R 220 (+ DR 905E fee), CR payer acct — the customer owes it again. // Refund of a posted cash/card payment (RF-): DR A/R 220 (+ DR 905E fee share), CR 201 / 202. // Invoice-to-account moves are A/R sub-ledger only when the GL side is 220 → 220. // Card data: NEVER stored. Only amount, optional last 4, processor reference / auth code. (function () { const D = window.DMS; if (!D || !D._mutate) return; const R2 = (n) => +(+n || 0).toFixed(2); const nowIso = () => new Date().toISOString(); const fmtD = (d) => new Date(d).toLocaleDateString("en-US", { month: "2-digit", day: "2-digit", year: "2-digit" }); const me = () => (D.userId && D.userId()) || ""; const canSetup = () => !!(D.isAdmin && D.isAdmin()) || !!(D.can && D.can("glEdit")); const canService = () => !!(D.can && D.can("service")); const canPostChange = () => !!(D.isAdmin && D.isAdmin()) || !!(D.can && D.can("glEdit")); const findRO = (st, id) => (st.repairOrders || []).find(r => r.id === id); const A = () => D._ACCT; const isRecon = (ro) => !!ro && (ro.recon === true || /^(internal|recon)$/i.test(String(ro.payType || "")) || (ro.ops || []).some(o => o.payType === "Internal")); // After work is added/edited on a recon RO, let the Recon module re-check the unit's budget. const touchRecon = (roId, reason) => { try { const ro = (D.repairOrders() || []).find(r => r.id === roId); if (ro && isRecon(ro) && D.reconCheck) D.reconCheck(roId, { reason: reason || ro.reconNote || (ro.notes && ro.notes.internal ? String(ro.notes.internal).split("\n")[0].slice(0, 140) : "") }); } catch (e) {} }; // Idempotent recon capitalization for POSTED internal payments (mirrors DMSCore closeRO). const syncReconPosted = (st, ro) => { const want = {}; (ro.payments || []).filter(p => !p.voided && p.posted && p.method === "internal").forEach(p => { const stock = String(p.stock || ro.internalStock || "").toUpperCase(); want[stock] = R2((want[stock] || 0) + (+p.amount || 0)); }); const had = (ro.reconPosted && typeof ro.reconPosted === "object") ? ro.reconPosted : {}; Object.keys({ ...had, ...want }).forEach(stock => { const delta = R2((want[stock] || 0) - (+had[stock] || 0)); const u = (st.inventory || []).find(x => String(x.stock || "").toUpperCase() === stock); if (u && delta) u.recon = R2((u.recon || 0) + delta); }); ro.reconPosted = want; }; const SVC_DEFAULTS = { cardFeePct: 3.5, cardFeeOn: true, reconWarnPct: 85, shopRate: 145, laborRates: [{ id: "SHOP", name: "Shop rate (customer pay)", rate: 145 }, { id: "WARR", name: "Warranty rate", rate: 128 }, { id: "INT", name: "Internal rate", rate: 95 }], invoiceFooter: "Thank you for your business. All parts are new unless otherwise specified. Warranty: 12 months / 12,000 miles on parts and labor performed by this dealership.", ro: null, money: null, // RC32 (r32fd): the RO screens' store settings — filled below from SVC_SETTING_LEAVES (serviceSettings.ro.* / .money.*) }; const JOB_PAY_TYPES = ["Customer", "Warranty", "Service Contract", "Internal", "Courtesy"]; // ══ RC32 (r32fd) — the service RO screens' store settings + the step helpers (contract: work/r32fd/INTERFACE.md) ══════════ // Stored in the serviceSettings record as serviceSettings.ro.{layout,workflow,lists,texts,techs}. and serviceSettings.money.. // Each LEAF below is one setting, saved whole. Read them ONLY through DMS.svcSettings().ro / .money (or DMS.svcResolveSettings(raw)): // every leaf is checked, and a missing or unreadable leaf falls back to its default — so an old record, an import or a station that // never saved them reads the same values everywhere. "today" = how the program works now (Setup › "Back to today's look"); // "def" = what a store starts with: today's value except the 15 "new look" rows the owner approved on the proposal page, his // answers Q1 (the customer approves the whole estimate) and Q3 (the RO moves to the next step by itself), and R16 as the proposal // showed it. Every money, posting, tax, numbering and print setting starts at today's value. who: "office" = Admin / F&I, Office // Manager, Accounting; "office+sm" = the office + the Service Manager; "admin" = Admin / F&I. later: shown locked, not changeable yet. const R32_STAGES = (Array.isArray(D.RO_STAGES) && D.RO_STAGES.length === 12) ? D.RO_STAGES.slice() : ["Write-Up", "Scheduled", "Checked In", "Dispatched", "In Diagnosis", "Estimate", "Awaiting Approval", "Authorized", "In Progress", "Complete", "Final Check", "Cashed Out"]; const R32_AA = "Awaiting Approval"; const STEP_GROUPS = [{ key: "writeup", n: 1, label: "Write-up" }, { key: "waiting", n: 2, label: "Waiting" }, { key: "progress", n: 3, label: "In progress" }, { key: "ready", n: 4, label: "Ready" }, { key: "closed", n: 5, label: "Closed" }]; const GROUP_N = { writeup: 1, waiting: 2, progress: 3, ready: 4, closed: 5 }; const STEP_GROUP_TODAY = { "Write-Up": "writeup", "Scheduled": "writeup", "Checked In": "writeup", "Dispatched": "waiting", "In Diagnosis": "waiting", "Estimate": "waiting", "Awaiting Approval": "waiting", "Authorized": "waiting", "In Progress": "progress", "Complete": "ready", "Final Check": "ready", "Cashed Out": "closed" }; const STEP_LOCKED = { "Write-Up": "writeup", "Awaiting Approval": "waiting", "In Progress": "progress", "Cashed Out": "closed" }; // always on, fixed group // today's fixed lists, copied from the page files they live in (test-ro-foundation.js checks each one against its file) const OP_CATALOG_TODAY = [ // ServiceTickets.jsx.txt SVC_OP_CATALOG { op: "OIL-LOF", desc: "Oil Change Lube Filter", hrs: 0.5, lab: 65.50 }, { op: "OIL-SYN", desc: "Synthetic Oil Change", hrs: 0.5, lab: 79.50 }, { op: "BRK-PADS-F", desc: "Front Brake Pad Replacement", hrs: 1.5, lab: 189.00 }, { op: "BRK-PADS-R", desc: "Rear Brake Pad Replacement", hrs: 1.5, lab: 189.00 }, { op: "BRK-RTR-F", desc: "Front Rotor Replacement", hrs: 1.2, lab: 158.00 }, { op: "TIRE-ROT", desc: "Tire Rotation", hrs: 0.3, lab: 32.40 }, { op: "TIRE-MNT", desc: "Mount and Balance — 4 Tires", hrs: 0.8, lab: 98.00 }, { op: "ALIGN-4W", desc: "4-Wheel Alignment", hrs: 1.0, lab: 129.00 }, { op: "DIAG-CEL", desc: "Diagnostic — Check Engine Light", hrs: 1.0, lab: 129.00 }, { op: "DIAG-AC", desc: "Diagnostic — Climate / A/C", hrs: 1.0, lab: 129.00 }, { op: "BATT-RPL", desc: "Battery Replacement", hrs: 0.4, lab: 48.00 }, { op: "STATE-INSP", desc: "MO State Safety Inspection", hrs: 0.5, lab: 12.00 }, { op: "WARR-PCM", desc: "Warranty — PCM Reflash", hrs: 1.2, lab: 142.00 }, { op: "SVC-30K", desc: "30,000 Mile Service", hrs: 2.5, lab: 318.00 }, { op: "SVC-60K", desc: "60,000 Mile Service", hrs: 3.5, lab: 442.00 }, { op: "LABOR", desc: "Custom Labor Operation", hrs: 1.0, lab: 145.00 }, ]; const ADVISORS_TODAY = ["Mark Service", "Shannon Keyes", "Bryce Tanner", "Delaney Riley"]; // ServiceTickets.jsx.txt SVC_ADVISORS const MPI_TODAY = ["Engine oil & filter", "Tire pressure / tread", "Brake pads / rotors", "Wiper blades", "Air filter", "Cabin filter", "Battery / charging", "Coolant level", "Belts & hoses", "Lights / signals", "Steering / suspension", "Exhaust system", "Fluid leaks", "Drive axle / CV boots"]; // ServiceTickets.jsx.txt RO_MPI_ITEMS const LUBE_PRINT_TODAY = MPI_TODAY.slice(0, 10); // printables.jsx Quick Lube Checklist (the first 10) const PAY_METHODS_TODAY = ["cash", "card", "invoice", "warranty", "contract", "courtesy", "internal", "sunbit"]; // ServiceTickets.jsx.txt RO_PAY_OPTS order const CARD_BRANDS_TODAY = ["Visa", "Mastercard", "Amex", "Discover", "Debit"]; const APPT_TYPES_TODAY = ["Oil change", "Maintenance", "Tires & alignment", "Brakes", "Battery", "Inspection", "Diagnosis", "Warranty / recall", "Repair", "Other"]; // DMSCore SVC_APPT_TYPES const BODY_RATES_TODAY = { body: 58, paint: 62, frame: 75, mechanical: 145 }; // DMSCore BODY_RATES const DOC_KEYS = ["workorder", "estimate", "invoice", "warranty", "lube", "history", "wo2", "rental"]; // PrintSelect.jsx.txt k2bPrintSelectRO const DOCS_TODAY = { workorder: { on: true, copies: 2 }, estimate: { on: false, copies: 1 }, invoice: { on: "notWarranty", copies: 1 }, warranty: { on: "warranty", copies: 1 }, lube: { on: false, copies: 1 }, history: { on: false, copies: 1 }, wo2: { on: false, copies: 1 }, rental: { on: false, copies: 1 } }; const CHIPS = ["mine", "open", "aged", "warranty", "internal", "closed", "all"]; const TABS_TODAY = ["cust", "jobs", "notes", "pay"], TABS_NEW = ["jobs", "cust", "notes", "mpi", "pay"]; const ROLE_KEYS = ["advisor", "parts", "tech", "office", "gm", "sales"]; const view = (chip, counters, acct, acctDetail, tabs) => ({ chip, counters, acct, acctDetail, tabs: tabs.slice() }); // ── value checks: check(value, base) → the clean value, or throws a plain-words reason. A group of values may leave parts out: // they come from base (the current value when saving, the default when reading). const r32Bad = (msg) => { const e = new Error(msg); e.r32 = true; throw e; }; const r32Clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v))); const cOneOf = (...vals) => (v) => (vals.includes(v) ? v : r32Bad("must be one of: " + vals.map(x => JSON.stringify(x)).join(", "))); const cBool = (v) => (typeof v === "boolean" ? v : r32Bad("must be on or off (true / false)")); const cNum = (lo, hi, dp) => (v) => { const n = typeof v === "string" && v.trim() !== "" ? +v : v; if (typeof n !== "number" || !isFinite(n) || n < lo || n > hi) r32Bad(`must be a number from ${lo} to ${hi}`); return dp != null ? +n.toFixed(dp) : n; }; const cInt = (lo, hi) => (v) => { const n = cNum(lo, hi)(v); if (Math.floor(n) !== n) r32Bad("must be a whole number"); return n; }; const cText = (max, min) => (v) => { if (typeof v !== "string") r32Bad("must be text"); const s = v.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "").trim(); if (s.length < (min || 0)) r32Bad("can't be blank"); if (s.length > max) r32Bad(`must be at most ${max} characters`); if (/[<>]/.test(s)) r32Bad("can't contain < or >"); return s; }; const cCode = (v) => { const s = cText(16, 1)(v).toUpperCase(); if (!/^[A-Z0-9][A-Z0-9_\-\/]*$/.test(s)) r32Bad("must be letters, digits, - or _ (like OIL-LOF)"); return s; }; const cList = (item, min, max, uniq) => (v) => { if (!Array.isArray(v)) r32Bad("must be a list"); if (v.length < min || v.length > max) r32Bad(min === max ? `needs exactly ${min} entries` : `needs ${min} to ${max} entries`); const out = v.map((x, i) => { try { return item(x); } catch (e) { return r32Bad(`entry ${i + 1} ${e.message}`); } }); if (uniq) { const seen = new Set(); out.forEach(x => { const k = JSON.stringify(typeof x === "string" ? x.toLowerCase() : x); if (seen.has(k)) r32Bad(`has ${JSON.stringify(x)} twice`); seen.add(k); }); } return out; }; const cSubset = (all, min) => cList(cOneOf(...all), min, all.length, true); const cShape = (spec, extra) => (v, base) => { if (!v || typeof v !== "object" || Array.isArray(v)) r32Bad("must be a group of values"); for (const k of Object.keys(v)) if (!Object.prototype.hasOwnProperty.call(spec, k)) r32Bad(`has an unknown part "${k}"`); const out = {}; for (const k of Object.keys(spec)) { const b = base && typeof base === "object" ? base[k] : undefined; if (!Object.prototype.hasOwnProperty.call(v, k)) { if (b === undefined) r32Bad(`is missing "${k}"`); out[k] = r32Clone(b); continue; } try { out[k] = spec[k](v[k], b); } catch (e) { r32Bad(`"${k}" ${e.message}`); } } return extra ? extra(out) : out; }; const cLocked = (today) => (v) => (JSON.stringify(v) === JSON.stringify(today) ? r32Clone(today) : r32Bad("comes in a later release — it can't be changed yet")); const cTabs = (v) => { const t = cSubset(["jobs", "cust", "notes", "mpi", "pay"], 1)(v); ["jobs", "cust", "notes", "pay"].forEach(k => { if (!t.includes(k)) r32Bad(`must keep the "${k}" tab (nothing is dropped)`); }); return t; }; const cView = cShape({ chip: cOneOf(...CHIPS), counters: cOneOf("tiles", "line", "off"), acct: cOneOf("tab", "more"), acctDetail: cOneOf("open", "folded"), tabs: cTabs }); const cDocs = cShape(DOC_KEYS.reduce((o, k) => { o[k] = cShape({ on: cOneOf(true, false, "warranty", "notWarranty"), copies: cInt(1, 9) }); return o; }, {})); const cSteps = (v, base) => { if (!Array.isArray(v) || v.length !== 12) r32Bad("must list all 12 stored steps"); const out = v.map((s, i) => { const b = base && base[i] ? base[i] : { stage: R32_STAGES[i], on: true, label: R32_STAGES[i], group: STEP_GROUP_TODAY[R32_STAGES[i]] }; if (!s || typeof s !== "object") r32Bad(`step ${i + 1} must be a group of values`); if (s.stage !== R32_STAGES[i]) r32Bad(`step ${i + 1} must be "${R32_STAGES[i]}" (the 12 stored step names and their order never change)`); for (const k of Object.keys(s)) if (!["stage", "on", "label", "group"].includes(k)) r32Bad(`step "${s.stage}" has an unknown part "${k}"`); const on = s.on === undefined ? b.on : s.on, label = s.label === undefined ? b.label : s.label, group = s.group === undefined ? b.group : s.group; if (typeof on !== "boolean") r32Bad(`step "${s.stage}": on must be true or false`); let lab; try { lab = cText(40, 1)(label); } catch (e) { r32Bad(`step "${s.stage}" name ${e.message}`); } if (!GROUP_N[group]) r32Bad(`step "${s.stage}" group must be one of writeup, waiting, progress, ready, closed`); const lock = STEP_LOCKED[s.stage]; if (lock && !on) r32Bad(`step "${s.stage}" can't be turned off`); if (lock && group !== lock) r32Bad(`step "${s.stage}" always shows in "${lock}"`); if (!lock && group === "closed") r32Bad(`only "Cashed Out" shows in "closed"`); return { stage: s.stage, on, label: lab, group }; }); for (let i = 1; i < 12; i++) if (GROUP_N[out[i].group] < GROUP_N[out[i - 1].group]) r32Bad(`"${out[i].stage}" can't show in an earlier group than "${out[i - 1].stage}" (steps keep their order)`); if (!out.some(s => s.on && s.group === "ready")) r32Bad(`at least one step must be on in "ready"`); return out; }; const STEPS_TODAY = R32_STAGES.map(s => ({ stage: s, on: true, label: s, group: STEP_GROUP_TODAY[s] })); // [path, id, who, label, today, def (undefined = same as today), check, later] const LEAF_ROWS = [ ["ro.layout.byRole", "R10", "office", "What each sign-in sees (first list button, counters, tabs, Accounting tab or under More, accounting detail)", ROLE_KEYS.reduce((o, k) => { o[k] = view("open", "tiles", "tab", "open", TABS_TODAY); return o; }, {}), { advisor: view("open", "line", "more", "folded", TABS_NEW), parts: view("open", "line", "more", "folded", TABS_NEW), tech: view("mine", "line", "more", "folded", TABS_NEW), office: view("open", "tiles", "tab", "open", TABS_NEW), gm: view("open", "line", "more", "folded", TABS_NEW), sales: view("all", "line", "more", "folded", TABS_NEW) }, cShape(ROLE_KEYS.reduce((o, k) => { o[k] = cView; return o; }, {}))], ["ro.layout.queue", "R11", "office+sm", "RO list: rows grouped by step or today's cards, which columns show, fold long groups", { style: "cards", groupBySteps: false, columns: ["ro", "cust", "tech", "pay", "total"], fold: "never" }, { style: "rows", groupBySteps: true, columns: ["ro", "cust", "waiting", "tech", "promised", "total"], fold: "never" }, cShape({ style: cOneOf("rows", "cards"), groupBySteps: cBool, columns: (v) => { const c = cSubset(["ro", "cust", "waiting", "tech", "promised", "total", "pay"], 1)(v); if (!c.includes("ro")) r32Bad("must keep the RO number"); return c; }, fold: cOneOf("never", "long") })], ["ro.layout.jobRows", "R12", "office+sm", "Jobs: fold each to one line (the one being priced or worked opens by itself), or keep every job open", "open", "fold", cOneOf("fold", "open")], ["ro.layout.moneyLine", "R13", "office", "Money line at the bottom: short (Tax, Total, Paid, Customer owes + Details) or every figure", "full", "short", cOneOf("short", "full")], ["ro.layout.afterClose", "R14", "office", "After Close RO & Post: the posting window, or a short note with Print invoice?", "window", undefined, cOneOf("window", "note")], ["ro.layout.threeCs", "R15", "office+sm", "Where the 3 C's are typed: on each job, on the Notes tab, or both", "both", undefined, cOneOf("both", "job", "notes")], ["ro.layout.addWork", "R16", "office+sm", "Add-work buttons shown: Bulk oil (on / off / only when the store has a bulk tank), Labor op, Blank job", { bulkOil: "on", laborOp: true, blankJob: true }, { bulkOil: "auto", laborOp: true, blankJob: true }, cShape({ bulkOil: cOneOf("auto", "on", "off"), laborOp: cBool, blankJob: cBool })], ["ro.layout.headerButtons", "R17", "office+sm", "Buttons kept in the RO header instead of under More: Carfax, History by VIN, Change customer, Flag hours", ["carfax", "vin", "cust"], [], cSubset(["carfax", "vin", "cust", "flag"], 0)], ["ro.workflow.steps", "R1", "office+sm", "Steps an RO goes through: each of the 12 on / off, its name, and its group of 5", STEPS_TODAY, undefined, cSteps], ["ro.workflow.okGate", "R2", "office+sm", "Customer OK is needed before work starts (always), and also before close (a screen check only)", "work", undefined, cOneOf("work", "work+close")], ["ro.workflow.autoStep", "R3", "office+sm", "Move the step by itself (one stored step at a time through the Advance check — never past the customer's OK)", false, true, cBool], ["ro.workflow.closeList", "R4", "office+sm", "Before you close list: off / remind only / must be done, and which reminders", { mode: "off", items: { miles: true, ok: true, tech: true, done: true, ccc: true } }, { mode: "remind", items: { miles: true, ok: true, tech: true, done: true, ccc: true } }, cShape({ mode: cOneOf("off", "remind", "must"), items: cShape({ miles: cBool, ok: cBool, tech: cBool, done: cBool, ccc: cBool }) })], ["ro.workflow.agedDays", "R5", "office+sm", "An RO counts as aged after this many days", 7, undefined, cInt(1, 365)], ["ro.workflow.promise", "R6", "office+sm", "Promise time on a new RO: off, or optional (suggested hours ahead; waiter hours ahead)", { mode: "off", suggestHrs: 2, waiterHrs: 1 }, { mode: "optional", suggestHrs: 2, waiterHrs: 1 }, cShape({ mode: cOneOf("off", "optional"), suggestHrs: cNum(0.25, 72, 2), waiterHrs: cNum(0.25, 72, 2) })], ["ro.lists.transport", "R7", "office+sm", "Transport choices on the RO", ["Drop-off", "Waiter", "Loaner", "Shuttle"], undefined, cList(cText(24, 1), 1, 12, true)], ["ro.lists.jobStatus", "R8", "office+sm", "Job status words (the stored codes PEND / ACTIVE / DONE / HOLD never change)", { PEND: "PEND", ACTIVE: "ACTIVE", DONE: "DONE", HOLD: "HOLD" }, { PEND: "Waiting", ACTIVE: "Working", DONE: "Done", HOLD: "On hold" }, cShape({ PEND: cText(24, 1), ACTIVE: cText(24, 1), DONE: cText(24, 1), HOLD: cText(24, 1) })], ["ro.lists.holdReasons", "R9", "office+sm", "Hold reasons", [], ["Waiting parts", "Waiting customer OK", "Sublet out", "Waiting warranty approval", "Comeback"], cList(cText(60, 1), 0, 20, true)], ["ro.lists.advisorsFromUsers", "L1", "office", "Advisors come from the user list (Service Advisor / Service Manager sign-ins); a new RO starts on the person signed in", false, true, cBool], ["ro.lists.newJobTech", "L2", "office+sm", "Technician for new jobs: the RO's New-jobs-go-to pick, none, or next tech up", "ro", undefined, cOneOf("ro", "none", "next")], ["ro.lists.inspection", "L3", "office+sm", "Inspection checklist items, its 3 grades, and the quick-lube print list", { items: MPI_TODAY, grades: ["OK", "Attn", "Now"], printItems: LUBE_PRINT_TODAY }, undefined, cShape({ items: cList(cText(60, 1), 1, 40, true), grades: cList(cText(12, 1), 3, 3, true), printItems: cList(cText(60, 1), 0, 40, true) })], ["ro.lists.bulkOilQts", "L7", "office+sm", "Bulk oil quick amounts (quarts) and the one picked first", { quick: [5, 6, 8], pick: 6 }, undefined, cShape({ quick: cList(cNum(0.5, 40, 2), 1, 4, true), pick: cNum(0.5, 40, 2) })], ["ro.lists.flagStart", "L8", "office+sm", "Flag hours window starts at (hours)", 1, undefined, cNum(0, 24, 1)], ["ro.lists.blankJob", "L9", "office+sm", "Blank job defaults: op code, description, hours", { op: "LABOR", desc: "New job", hrs: 1 }, undefined, cShape({ op: cCode, desc: cText(80, 1), hrs: cNum(0, 99, 2) })], ["ro.lists.apptTypes", "L10", "office+sm", "Appointment service types", APPT_TYPES_TODAY, undefined, cList(cText(40, 1), 1, 30, true)], ["ro.texts.storeName", "X1", "office", "Store name on the customer's e-sign page and prints (blank = the store profile)", "", undefined, cText(80, 0)], ["ro.texts.authText", "X2", "office", "Repair authorization wording (blank = the standard wording) and whether an advisor may edit it on one RO", { text: "", perRoEdit: true }, undefined, cShape({ text: cText(4000, 0), perRoEdit: cBool })], ["ro.texts.estimateLine", "X3", "office", "Estimate wording line (blank = today's line)", "", undefined, cText(400, 0)], ["ro.texts.approvalAmount", "X4", "office", "What the customer approves shows: labor only, or the whole estimate (labor + parts + sublet + tax)", "labor", "whole", cOneOf("labor", "whole")], ["ro.texts.packets", "X5", "office", "Print packets: which documents are ticked and how many copies (open RO / closed RO), and documents hidden", { open: DOCS_TODAY, closed: DOCS_TODAY, hide: [] }, { open: DOCS_TODAY, closed: DOCS_TODAY, hide: ["rental"] }, cShape({ open: cDocs, closed: cDocs, hide: cSubset(DOC_KEYS, 0) })], ["ro.texts.esignSend", "X6", "office", "E-sign link: show it, text it or email it; fill in the customer's mobile number", { via: "show", fillMobile: false }, { via: "show", fillMobile: true }, cShape({ via: cOneOf("show", "text", "email"), fillMobile: cBool })], ["ro.texts.newRo", "X8", "office", "New RO: allow walk-in ROs; add a new customer from the RO", { walkIn: true, addCustomer: false }, undefined, cShape({ walkIn: cBool, addCustomer: cBool })], ["ro.techs.myJobsOnPhone", "T2", "office+sm", "Technician phones open on My jobs (view only)", false, true, cBool], ["ro.techs.showOwnHours", "T3", "office+sm", "Techs see their own flagged hours for today", false, true, cBool], ["ro.techs.rateViewers", "T4", "office", "Who sees technician pay rates on Tech Productivity", "all", undefined, cOneOf("all", "office+sm")], ["ro.techs.selfUpdate", "T5", "office", "Techs update their own jobs", false, undefined, null, true], ["ro.techs.partRequests", "T6", "office", "Techs ask for a part from the job", false, undefined, null, true], ["ro.techs.timeClock", "T7", "office", "Time clock on jobs", false, undefined, null, true], ["money.opCatalog", "L4", "office", "Labor op list behind + Labor op (a new job's starting price only), or the button off", { on: true, ops: OP_CATALOG_TODAY }, undefined, cShape({ on: cBool, ops: (v) => { const l = cList(cShape({ op: cCode, desc: cText(80, 1), hrs: cNum(0, 99, 2), lab: cNum(0, 100000, 2) }), 0, 200)(v); const seen = new Set(); l.forEach(o => { if (seen.has(o.op)) r32Bad(`has op ${o.op} twice`); seen.add(o.op); }); return l; } })], ["money.payMethods", "L5", "office", "Payment methods offered (Invoice stays office only), the one picked first, card brands", { offered: PAY_METHODS_TODAY, first: "card", cardBrands: CARD_BRANDS_TODAY }, undefined, cShape({ offered: cSubset(PAY_METHODS_TODAY, 1), first: cOneOf(...PAY_METHODS_TODAY), cardBrands: cList(cText(24, 1), 0, 12, true) }, (o) => (o.offered.includes(o.first) ? o : r32Bad(`"first" must be one of the methods offered`)))], ["money.drawerRemember", "L6", "office", "Cash drawer: remember the last drawer used on each computer (the drawer reference stays required)", false, true, cBool], ["money.defaultPay", "L11", "office", "Pay type for new jobs (recon ROs always Internal)", "Customer", undefined, cOneOf(...JOB_PAY_TYPES)], ["money.shopSupplies", "M1", "office", "Shop supplies / hazmat fee", { on: false }, undefined, null, true], ["money.laborRates", "M3", "office", "Labor rate used for each pay type (an id from the labor rates list; a job's starting rate only)", { Customer: "SHOP", Warranty: "WARR", Internal: "INT", "Service Contract": "SHOP", Courtesy: "SHOP" }, undefined, cShape(JOB_PAY_TYPES.reduce((o, k) => { o[k] = cCode; return o; }, {}))], ["money.subletMarkup", "M4", "office", "Sublet markup: fill in the sale price from the cost", { on: false, factor: 1.25 }, undefined, cShape({ on: cBool, factor: cNum(1, 5, 4) })], ["money.bodyRates", "M5", "office", "Body shop labor rates by type", BODY_RATES_TODAY, undefined, cShape({ body: cNum(0.01, 1000, 2), paint: cNum(0.01, 1000, 2), frame: cNum(0.01, 1000, 2), mechanical: cNum(0.01, 1000, 2) })], ["money.roNumber", "X7", "admin", "RO number start and next number", { prefix: "RO-", floor: 1001 }, undefined, null, true], // RC35: "today" = the store's numbering (below) ]; const LEAVES = LEAF_ROWS.map(([path, id, who, label, today, def, check, later]) => ({ path, id, who, label, later: !!later, today: r32Clone(today), def: r32Clone(def === undefined ? today : def), check: later ? cLocked(today) : check })); const LEAF_BY_PATH = LEAVES.reduce((o, L) => { o[L.path] = L; return o; }, {}); // RC35 (brd): X7's today IS the store's RO numbering (Owner Panel > Stores; the server checks a save against the active store's) const svcStoreRoNumber = () => { const r = ((window.K2BStore && window.K2BStore.options()) || {}).roNumber || {}; const fl = Number(r.floor); return { prefix: "RO-", floor: Number.isInteger(fl) && fl > 0 ? fl : 1001 }; }; { const LX7 = LEAF_BY_PATH["money.roNumber"]; Object.defineProperty(LX7, "today", { get: svcStoreRoNumber, enumerable: true }); Object.defineProperty(LX7, "def", { get: svcStoreRoNumber, enumerable: true }); LX7.check = (v) => cLocked(svcStoreRoNumber())(v); } const R32_GROUP_PATHS = { ro: ["layout", "workflow", "lists", "texts", "techs"], money: [] }; // today's top-level keys and who changes them (unchanged keys; the server guard is lane rset's) const LEGACY_WHO = { cardFeePct: "office", cardFeeOn: "office", shopRate: "office", laborRates: "office", reconWarnPct: "office", reconLaborRate: "office", invoiceFooter: "office", tax: "office+sm", feeAcct: "office+sm" }; const r32Get = (o, path) => path.split(".").reduce((a, k) => (a && typeof a === "object" && !Array.isArray(a) ? a[k] : undefined), o); const r32Set = (o, path, v) => { const ks = path.split("."); let a = o; for (let i = 0; i < ks.length - 1; i++) { if (!a[ks[i]] || typeof a[ks[i]] !== "object" || Array.isArray(a[ks[i]])) a[ks[i]] = {}; a = a[ks[i]]; } if (v === undefined) delete a[ks[ks.length - 1]]; else a[ks[ks.length - 1]] = v; }; const r32Blank = () => ({ ro: { layout: {}, workflow: {}, lists: {}, texts: {}, techs: {} }, money: {} }); const r32Build = (which) => { const o = r32Blank(); LEAVES.forEach(L => r32Set(o, L.path, r32Clone(L[which]))); return o; }; const SVC_RO_TODAY = r32Build("today"); const SVC_RO_DEFAULTS = r32Build("def"); [SVC_RO_TODAY, SVC_RO_DEFAULTS].forEach((o) => Object.defineProperty(o.money, "roNumber", { get: svcStoreRoNumber, enumerable: true, configurable: true })); SVC_DEFAULTS.ro = SVC_RO_DEFAULTS.ro; SVC_DEFAULTS.money = SVC_RO_DEFAULTS.money; // raw serviceSettings record → { ro, money, invalid: [paths that were stored but unreadable] }. opts.today: missing → today's value. const resolveSettings = (raw, opts) => { const which = opts && opts.today ? "today" : "def"; const out = r32Blank(), invalid = []; for (const L of LEAVES) { const d = r32Clone(L[which]); const v = r32Get(raw, L.path); let val = d; if (v !== undefined && v !== null) { try { val = L.check(r32Clone(v), d); } catch (e) { invalid.push(L.path); val = d; } } r32Set(out, L.path, val); } out.invalid = invalid; return out; }; const rawSettings = () => (D.serviceSettings ? (D.serviceSettings() || {}) : {}); const rsOf = (o) => (o && o.settings && o.settings.ro && o.settings.money ? o.settings : resolveSettings(o && o.raw ? o.raw : rawSettings())); const rawSM = () => !!(D._svcRawServiceManager && D._svcRawServiceManager()); const canWho = (who) => (who === "admin" ? !!(D.isAdmin && D.isAdmin()) : who === "office" ? canSetup() : who === "office+sm" ? (canSetup() || rawSM()) : false); // ── the 5 steps over the 12 stored steps ── const stageIdx = (s) => { const i = R32_STAGES.indexOf(s || "Write-Up"); return i < 0 ? 0 : i; }; // = DMS.roStageIndex (unknown → Write-Up) const stepsOf = (o) => rsOf(o).ro.workflow.steps; const svcSteps = (o) => stepsOf(o).map((s, i) => ({ ...s, index: i, locked: !!STEP_LOCKED[s.stage], groupN: GROUP_N[s.group] })); const svcStepsInUse = (o) => stepsOf(o).filter(s => s.on).map(s => s.stage); // a stage name → its group; an RO → its group (status Closed → "closed"; an OPEN RO never reads "closed": it is "ready" until Close RO posts it) const svcStepGroup = (x, o) => { const steps = stepsOf(o); if (x && typeof x === "object") { if (x.status === "Closed") return "closed"; const g = steps[stageIdx(x.stage)].group; return g === "closed" ? "ready" : g; } return steps[stageIdx(x)].group; }; const liveJobs = (ro) => ((ro && ro.ops) || []).filter(j => j && j.approval !== "declined"); // the customer's OK: given (ro.authorization, today's gate) and needed (jobs not covered by it: a job added after the OK that was not // approved on its own brings the OK card back — only when the signing saved which jobs it covered, authorization.estimate.jobIds) const svcOkState = (ro) => { const a = ro && ro.authorization ? ro.authorization : null; const live = liveJobs(ro); const ids = a && a.estimate && Array.isArray(a.estimate.jobIds) ? a.estimate.jobIds.map(String) : null; const uncovered = a ? (ids ? live.filter(j => j.approval !== "approved" && !ids.includes(String(j.id))) : []) : live; return { given: !!a, needed: live.length > 0 && (!a || uncovered.length > 0), uncoveredIds: uncovered.map(j => j.id), method: a ? a.method || "" : "", by: a ? a.by || "" : "", at: a ? a.at || "" : "" }; }; const firstUsedAfter = (steps, cur, group) => { for (let i = cur + 1; i < steps.length; i++) if (steps[i].on && steps[i].group === group) return R32_STAGES[i]; return null; }; const owesOf = (ro) => { try { const m = D.roMoney ? D.roMoney(ro) : null; return m ? +m.customerBalance || 0 : 0; } catch (e) { return 0; } }; const fmt$ = (n) => "$" + (Number(n) || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); // The yellow next-step button: { key, label, enabled, target (a stored step for svcMoveTo, or null), ... }. key: "jobs" | "ok" | "shop" | // "start" | "ready" | "pay" | "close" | "invoice" | null (hidden — a read-only role). opts: { owes, readOnly, canClose, settings }. const svcNextStep = (ro, o) => { o = o || {}; if (!ro) return { key: null, hidden: true }; const readOnly = o.readOnly != null ? !!o.readOnly : !canService(); if (readOnly) return { key: null, hidden: true, readOnly: true }; if (ro.status === "Closed") return { key: "invoice", label: "🖨 Invoice", enabled: true, target: null }; const rs = rsOf(o), steps = rs.ro.workflow.steps, cur = stageIdx(ro.stage), g = svcStepGroup(ro, { settings: rs }); const live = liveJobs(ro), ok = svcOkState(ro); if (!(ro.ops || []).length) return { key: "jobs", label: "Add jobs", enabled: true, target: null }; if (ok.needed && g !== "ready") { const r = { key: "ok", label: "Get customer OK ▸", enabled: true, target: null, ok }; const t = g === "writeup" ? firstUsedAfter(steps, cur, "waiting") : null; if (t && stageIdx(t) <= stageIdx(R32_AA)) r.alt = { key: "diag", label: "Send to shop for diagnosis", target: t }; // Dispatched comes before Awaiting Approval return r; } if (live.length && g === "writeup") { const t = firstUsedAfter(steps, cur, "waiting"); if (t) return { key: "shop", label: "Send to shop ▸", enabled: true, target: t, needTech: live.filter(j => !j.techId).map(j => j.id) }; } if (live.length && (g === "writeup" || g === "waiting")) return { key: "start", label: "Start work ▸", enabled: true, target: "In Progress" }; if (live.length && g === "progress") { const t = firstUsedAfter(steps, cur, "ready"), done = live.filter(j => j.status === "DONE").length; if (done < live.length) return { key: "ready", label: "Mark ready ▸", enabled: false, target: t, hint: `${done} of ${live.length} jobs done`, left: live.filter(j => j.status !== "DONE").map(j => j.id) }; return { key: "ready", label: "Mark ready ▸", enabled: true, target: t }; } // Ready (or every job declined): take the payment, then close const owes = o.owes != null ? +o.owes || 0 : owesOf(ro); if (owes > 0.005) return { key: "pay", label: `Take payment · ${fmt$(owes)}`, enabled: true, target: null, owes }; const canClose = o.canClose != null ? !!o.canClose : !!(D.fopsCanCloseRO && D.fopsCanCloseRO()); if (!canClose) return { key: "close", label: "Close RO & Post →", enabled: false, closeNote: true, hint: "Closing: service advisor / office", target: null, owes }; return { key: "close", label: "Close RO & Post →", enabled: true, target: null, owes }; }; // svcMovePlan(ro, target) — pure: the stored steps an RO passes through to reach target (a stored step, or a group key = the first // step in use of that group after the current one). Forward = one stored step at a time with TODAY'S Advance check (DMSCore advanceRO: // can't leave "Awaiting Approval" without ro.authorization); a turned-off step is passed as { skipped: true }; the RO never rests on a // turned-off step. Back = one move to the target (as today's Back button). Refused moves change nothing. const svcMovePlan = (ro, target, o) => { if (!ro) return { ok: false, msg: "RO not found." }; if (ro.status === "Closed") return { ok: false, closed: true, msg: `${ro.id} is closed.` }; const steps = stepsOf(o), cur = stageIdx(ro.stage); let t = R32_STAGES.indexOf(target); if (t < 0 && GROUP_N[target]) { const s = firstUsedAfter(steps, cur, target); t = s ? R32_STAGES.indexOf(s) : (steps[cur].group === target ? cur : -1); } if (t < 0) return { ok: false, msg: `Unknown step "${String(target)}".` }; if (t === cur) return { ok: true, noop: true, from: R32_STAGES[cur], to: R32_STAGES[cur], steps: [] }; if (t < cur) { let land = t; while (land > 0 && !steps[land].on) land--; return { ok: true, back: true, from: R32_STAGES[cur], to: R32_STAGES[land], steps: [{ stage: R32_STAGES[land], skipped: false }] }; } let land = t; while (land < R32_STAGES.length - 1 && !steps[land].on) land++; const out = []; for (let i = cur; i < land; i++) { if (R32_STAGES[i] === R32_AA && !ro.authorization) return { ok: false, gate: true, stoppedAt: R32_AA, from: R32_STAGES[cur], to: R32_STAGES[land], msg: "Estimate must be authorized by the customer first." }; out.push({ stage: R32_STAGES[i + 1], skipped: !steps[i + 1].on }); } return { ok: true, from: R32_STAGES[cur], to: R32_STAGES[land], steps: out }; }; // svcMoveTo(roId, target, { auto }) — the ONE way the new screens move an RO: the plan above, saved once (one _mutate), one audit line per step. const svcMoveTo = (roId, target, o) => { o = o || {}; if (!canService()) return { ok: false, denied: true, msg: "Your role can't edit repair orders." }; const ro0 = (D.repairOrders() || []).find(r => r.id === roId); const plan = svcMovePlan(ro0, target, o); if (!plan.ok || plan.noop) return plan.ok ? { ...plan, msg: `${roId} is already at ${plan.to}.` } : plan; let err = null; const at = nowIso(); D._mutate((st) => { const ro = findRO(st, roId); if (!ro || ro.status === "Closed" || stageIdx(ro.stage) !== stageIdx(ro0.stage) || !ro.authorization !== !ro0.authorization) { err = "The RO changed meanwhile — look again and retry."; return; } ro.stageHist = { ...(ro.stageHist || {}) }; plan.steps.forEach(s => { ro.stageHist[s.stage] = s.skipped ? { skipped: true } : at; }); ro.stage = plan.to; if (o.techId && !ro.techId) ro.techId = String(o.techId); // RC33 (part C, RC32 acceptance F2): the technician picked by Start work is the RO's too when it has none (list column, invoice / work-order header) }); if (err) return { ok: false, msg: err }; plan.steps.forEach(s => D._audit("RO Stage", `${roId} → ${s.stage}${s.skipped ? " (skipped — turned off in Service Setup)" : ""}${o.auto ? " (moved by itself)" : ""}`)); return { ok: true, from: plan.from, to: plan.to, steps: plan.steps, back: !!plan.back, msg: `${roId} moved to ${plan.to}.` }; }; // R3: where an event moves the RO by itself (null = stay). event: "ok" (customer OK received) | "working" (a job set to ACTIVE) | "done" (a job set to DONE) const svcAutoStep = (ro, event, o) => { const rs = rsOf(o); if (!rs.ro.workflow.autoStep || !ro || ro.status === "Closed") return null; const steps = rs.ro.workflow.steps, cur = stageIdx(ro.stage), g = svcStepGroup(ro, { settings: rs }), live = liveJobs(ro); if (event === "ok") return g === "writeup" ? firstUsedAfter(steps, cur, "waiting") : null; if (event === "working") return ro.authorization && (g === "writeup" || g === "waiting") ? "In Progress" : null; if (event === "done") return g === "progress" && live.length && live.every(j => j.status === "DONE") ? firstUsedAfter(steps, cur, "ready") : null; return null; }; const svcAutoMove = (roId, event, o) => { const ro = (D.repairOrders() || []).find(r => r.id === roId); const t = svcAutoStep(ro, event, o); if (!t) return { ok: true, noop: true, msg: "" }; const r = svcMoveTo(roId, t, { ...(o || {}), auto: true }); return r.ok ? r : { ...r, noop: true }; // a refused automatic move leaves the RO where it is (the gate) — never an error for the person }; // svcLayout(roBoxWidth): one DOM tree per layout (SPEC 6 rule 3) const svcLayout = (w) => (+w >= 1000 ? "desk" : +w >= 600 ? "narrow" : "phone"); // where the ONE data-ro-close goes: "footer" | "phonebar" | "next" (the yellow button: Ready and $0 owed) | null (none: closed / read-only / a role that can't close) const svcCloseSpot = (ro, layout, o) => { o = o || {}; if (!ro || ro.status === "Closed") return null; const readOnly = o.readOnly != null ? !!o.readOnly : !canService(); const canClose = o.canClose != null ? !!o.canClose : !!(D.fopsCanCloseRO && D.fopsCanCloseRO()); if (readOnly || !canClose) return null; const n = svcNextStep(ro, { ...o, readOnly: false, canClose: true }); if (n.key === "close") return "next"; return layout === "phone" ? "phonebar" : "footer"; }; // R4 "Before you close": { mode, items: [{ key, label, done, must }], left, blocking }. blocking only holds back the screen's close // button ("must be done", or R2 "also before close"); the close command itself is unchanged. const svcCloseChecklist = (ro, o) => { const rs = rsOf(o), cl = rs.ro.workflow.closeList, gate = rs.ro.workflow.okGate; if (!ro) return { mode: cl.mode, items: [], left: 0, blocking: false }; const live = liveJobs(ro), miles = String(ro.miles != null && ro.miles !== "" ? ro.miles : (ro.veh && ro.veh.miles) || "").replace(/[^0-9.]/g, ""); const has = (s) => !!String(s == null ? "" : s).trim(); const defs = [["miles", "Mileage in", +miles > 0], ["ok", "Customer OK", !!ro.authorization], ["tech", "A technician on every job", live.every(j => has(j.techId))], ["done", "Every approved job Done", live.every(j => j.status === "DONE")], ["ccc", "Cause and correction typed on every job", live.every(j => has(j.cause) && has(j.correction))]]; const items = []; if (cl.mode !== "off") defs.forEach(([key, label, done]) => { if (cl.items[key]) items.push({ key, label, done, must: cl.mode === "must" }); }); if (gate === "work+close") { const it = items.find(x => x.key === "ok"); if (it) it.must = true; else items.push({ key: "ok", label: "Customer OK", done: !!ro.authorization, must: true }); } return { mode: cl.mode, items, left: items.filter(x => !x.done).length, blocking: items.some(x => x.must && !x.done) }; }; // L1 advisors: { names, pick, fromUsers }. opts: { current (the RO's saved advisor), roster, me, settings } const ADVISOR_ROLE = /^service(\s*(advisor|writer|manager|mgr))?$/i; const svcAdvisors = (o) => { o = o || {}; const rs = rsOf(o), current = o.current ? String(o.current) : ""; if (!rs.ro.lists.advisorsFromUsers) return { names: ADVISORS_TODAY.slice(), pick: ADVISORS_TODAY[0], fromUsers: false }; // today: 4 names, a new RO starts on the first const roster = Array.isArray(o.roster) ? o.roster : (D.roster ? D.roster() || [] : []); const me = o.me != null ? String(o.me) : (D.userId ? D.userId() : ""); let names = []; roster.filter(u => u && ADVISOR_ROLE.test(String(u.role || "").trim())).forEach(u => { const n = String(u.name || u.id || "").trim(); if (n && !names.includes(n)) names.push(n); }); names.sort((a, b) => a.localeCompare(b)); const fromUsers = names.length > 0; if (!fromUsers) names = ADVISORS_TODAY.slice(); // roster not loaded (offline) → today's names const mine = roster.find(u => u && String(u.id) === me && ADVISOR_ROLE.test(String(u.role || "").trim())); const myName = mine ? String(mine.name || mine.id || "").trim() : ""; if (current && !names.includes(current)) names.push(current); // an old RO keeps the advisor it was saved with return { names, pick: current || (myName && names.includes(myName) ? myName : names[0]), fromUsers }; }; // R10: which profile a sign-in uses, and its view const svcRoleKey = (role) => { const r = role != null ? String(role) : (D.userRole ? D.userRole() : ""); if (/^(Admin \/ F&I|Office Manager|Accounting)$/.test(r)) return "office"; if (r === "Technician") return "tech"; if (r === "Sales Manager") return "gm"; if (r === "Sales") return "sales"; if (r === "Parts") return "parts"; return "advisor"; }; const svcRoleView = (o) => { const rs = rsOf(o); return r32Clone(rs.ro.layout.byRole[svcRoleKey(o && o.role)]); }; // L11: pay type for a new job (today's ServiceTickets defPay rule; recon / internal ROs always Internal) const svcDefaultPayType = (ro, o) => (ro && (ro.payType === "Internal" || ro.recon) ? "Internal" : rsOf(o).money.defaultPay); // P1-P6 "Aa My view": browser storage per sign-in + device type, k2b.svc.myview.. (never the server) const MYVIEW_DEFAULTS = { size: "standard", bigText: false, list: "auto", remember: true, start: "store", open: {} }; const svcDeviceType = (w) => { let x = +w; if (!(x > 0)) { try { x = +(window.screen && window.screen.width) || +window.innerWidth || 1366; } catch (e) { x = 1366; } } return x < 600 ? "phone" : x < 1100 ? "tablet" : x < 1600 ? "laptop" : "desk"; }; const myViewKey = (o) => { const u = String((o && o.userId) || (D.userId && D.userId()) || "").trim(); const dev = o && ["desk", "laptop", "tablet", "phone"].includes(o.device) ? o.device : svcDeviceType(o && o.width); return u ? { key: `k2b.svc.myview.${u}.${dev}`, userId: u, device: dev } : { key: null, userId: "", device: dev }; }; const cleanView = (v, base) => { // a value that isn't one of the choices keeps base (the defaults when reading) const b = base || MYVIEW_DEFAULTS, out = { size: b.size, bigText: b.bigText, list: b.list, remember: b.remember, start: b.start, open: {} }; if (!v || typeof v !== "object" || Array.isArray(v)) return out; if (["standard", "compact"].includes(v.size)) out.size = v.size; if (typeof v.bigText === "boolean") out.bigText = v.bigText; if (["auto", "full", "strip"].includes(v.list)) out.list = v.list; if (typeof v.remember === "boolean") out.remember = v.remember; if (["store", "mine"].includes(v.start)) out.start = v.start; if (out.remember && v.open && typeof v.open === "object" && !Array.isArray(v.open)) Object.keys(v.open).slice(0, 200).forEach(k => { if (k.length <= 80 && typeof v.open[k] === "boolean") out.open[k] = v.open[k]; }); return out; }; const svcMyView = (o) => { const k = myViewKey(o); let raw = null; if (k.key) { try { const s = window.localStorage.getItem(k.key); raw = s ? JSON.parse(s) : null; } catch (e) { raw = null; } } return { ...cleanView(raw), stored: !!raw, userId: k.userId, device: k.device }; }; const svcMyViewSave = (patch, o) => { const k = myViewKey(o), cur = svcMyView(o), p = patch && typeof patch === "object" ? patch : {}; const next = cleanView({ ...p, open: p.open && typeof p.open === "object" && !Array.isArray(p.open) ? { ...cur.open, ...p.open } : cur.open }, cur); if (!k.key) return { ok: false, msg: "Sign in first — your view is kept per sign-in.", view: next }; try { window.localStorage.setItem(k.key, JSON.stringify(next)); return { ok: true, view: { ...next, stored: true, userId: k.userId, device: k.device } }; } catch (e) { return { ok: false, msg: "This browser would not keep your view (a private window or blocked storage) — it lasts until you reload.", view: next }; } }; const svcMyViewReset = (o) => { const k = myViewKey(o); if (k.key) { try { window.localStorage.removeItem(k.key); } catch (e) {} } return { ok: true, view: { ...MYVIEW_DEFAULTS, open: {}, stored: false, userId: k.userId, device: k.device } }; }; // the leaves a save touches: { ok, leaves: [{ L, value }] } — refuses an unknown path (a typo never saves silently) const r32TakeLeaves = (p) => { const leaves = []; for (const top of ["ro", "money"]) { if (!(top in p)) continue; const v = p[top]; delete p[top]; if (v == null) continue; if (typeof v !== "object" || Array.isArray(v)) return { ok: false, msg: `"${top}" must be a group of settings.` }; const walk = (obj, prefix, depth) => { for (const k of Object.keys(obj)) { const path = prefix + "." + k; if (LEAF_BY_PATH[path]) { leaves.push({ L: LEAF_BY_PATH[path], value: obj[k] }); continue; } if (depth === 0 && top === "ro" && R32_GROUP_PATHS.ro.includes(k) && obj[k] && typeof obj[k] === "object" && !Array.isArray(obj[k])) { const e = walk(obj[k], path, 1); if (e) return e; continue; } return `"${path}" is not a service setting.`; } return null; }; const e = walk(v, top, 0); if (e) return { ok: false, msg: e }; } return { ok: true, leaves }; }; const r32WhoWords = { office: "the office (Admin / F&I, Office Manager, Accounting)", "office+sm": "the office or the Service Manager", admin: "Admin / F&I" }; // Anything that looks like a full card number (13–19 digits) is refused outright. const looksLikePAN = (v) => { const d = String(v == null ? "" : v).replace(/[\s-]/g, ""); return /\d{13,19}/.test(d); }; const svcSettings = () => { const s = D.serviceSettings ? (D.serviceSettings() || {}) : {}; const out = { ...SVC_DEFAULTS, ...s }; out.cardFeePct = s.cardFeePct != null && s.cardFeePct !== "" ? +s.cardFeePct : SVC_DEFAULTS.cardFeePct; out.cardFeeOn = s.cardFeeOn != null ? !!s.cardFeeOn : true; out.reconWarnPct = s.reconWarnPct != null && s.reconWarnPct !== "" ? +s.reconWarnPct : 85; out.shopRate = s.shopRate != null && s.shopRate !== "" ? +s.shopRate : SVC_DEFAULTS.shopRate; out.laborRates = Array.isArray(s.laborRates) && s.laborRates.length ? s.laborRates : SVC_DEFAULTS.laborRates; const rs = resolveSettings(s); out.ro = rs.ro; out.money = rs.money; // RC32 (r32fd): every new leaf checked, missing → its default return out; }; const saveServiceSettings = (patch) => { const p = { ...(patch || {}) }; // RC32 (r32fd): p.ro / p.money carry the new store settings — each leaf checked and saved whole (null = back to its default), // guarded by who may change it (SVC_SETTING_LEAVES who). A save of today's keys (or of nothing) keeps today's office gate. const nx = r32TakeLeaves(p); if (!nx.ok) return { ok: false, msg: nx.msg }; if ((Object.keys(p).length || !nx.leaves.length) && !canSetup()) return { ok: false, denied: true, msg: "Only an office manager or admin can change service settings." }; const refused = nx.leaves.filter(x => !canWho(x.L.who)); if (refused.length) { const w = refused[0].L.who; return { ok: false, denied: true, msg: `Only ${r32WhoWords[w]} can change: ${refused.filter(x => x.L.who === w).map(x => x.L.label.split(":")[0]).join("; ")}.` }; } const cur = svcSettings(), leafVals = []; for (const x of nx.leaves) { if (x.value === null) { leafVals.push({ L: x.L, value: undefined }); continue; } try { leafVals.push({ L: x.L, value: x.L.check(r32Clone(x.value), r32Get(cur, x.L.path)) }); } catch (e) { return { ok: false, msg: `${x.L.id} (${x.L.label.split(":")[0]}) ${e.message}.` }; } } if (p.cardFeePct != null) { const v = +p.cardFeePct; if (isNaN(v) || v < 0 || v > 10) return { ok: false, msg: "Card fee must be between 0 and 10%." }; p.cardFeePct = R2(v); } if (p.reconWarnPct != null) { const v = +p.reconWarnPct; if (isNaN(v) || v < 1 || v > 200) return { ok: false, msg: "Recon warning must be between 1 and 200%." }; p.reconWarnPct = v; } if (p.reconLaborRate != null) { if (p.reconLaborRate === "") p.reconLaborRate = null; else { const v = +p.reconLaborRate; if (isNaN(v) || v <= 0) return { ok: false, msg: "Recon labor rate must be a positive number (or blank)." }; p.reconLaborRate = R2(v); } } if (p.shopRate != null) { const v = +p.shopRate; if (isNaN(v) || v <= 0) return { ok: false, msg: "Shop rate must be a positive number." }; p.shopRate = R2(v); } if (p.laborRates) { if (p.laborRates.some(r => !r.name || !(+r.rate > 0))) return { ok: false, msg: "Every labor rate needs a name and a rate above $0." }; p.laborRates = p.laborRates.map(r => ({ id: r.id || D._uid("LR"), name: String(r.name), rate: R2(r.rate) })); } const before = svcSettings(); const beforeRaw = r32Clone(rawSettings()) || {}; D._mutate((st) => { st.serviceSettings = { ...(st.serviceSettings || {}), ...p, updatedAt: nowIso(), updatedBy: me() }; if (leafVals.length) { // RC32: only the leaves saved change; every other stored leaf stays as it is const ss = st.serviceSettings; ["ro", "money"].forEach(k => { ss[k] = ss[k] && typeof ss[k] === "object" && !Array.isArray(ss[k]) ? r32Clone(ss[k]) : {}; }); leafVals.forEach(x => r32Set(ss, x.L.path, x.value)); } }); const changed = Object.keys(p).filter(k => JSON.stringify(before[k]) !== JSON.stringify(p[k])); const short = (v) => { const t = JSON.stringify(v === undefined ? "(default)" : v); return t.length > 120 ? t.slice(0, 117) + "…" : t; }; const leafChanged = leafVals.filter(x => JSON.stringify(r32Get(beforeRaw, x.L.path)) !== JSON.stringify(x.value)); D._audit("Service Settings", changed.map(k => `${k}: ${JSON.stringify(before[k])} → ${JSON.stringify(p[k])}`) .concat(leafChanged.map(x => `${x.L.path}: ${short(r32Get(beforeRaw, x.L.path))} → ${short(x.value)}`)).join("; ") || "saved (no change)"); return { ok: true, msg: "Service settings saved.", changed: changed.concat(leafChanged.map(x => x.L.path)) }; }; // ── Standard jobs (templates) ─────────────────────────────────────────── const normStdJob = (r) => ({ id: r.id || D._uid("SJ"), desc: String(r.desc || "").trim(), laborOp: String(r.laborOp || "").trim().toUpperCase(), estHours: R2(r.estHours), rateMode: r.rateMode === "flat" ? "flat" : "rate", rateSource: r.rateSource === "custom" ? "custom" : "shop", rate: R2(r.rate), flat: R2(r.flat), payType: JOB_PAY_TYPES.includes(r.payType) ? r.payType : "Customer", parts: (r.parts || []).filter(p => p && (p.part || p.desc)).map(p => ({ part: String(p.part || "").trim().toUpperCase(), desc: String(p.desc || "").trim(), qty: +p.qty || 1, price: R2(p.price), cost: p.cost != null && p.cost !== "" ? R2(p.cost) : null })), custDesc: String(r.custDesc || ""), techNotes: String(r.techNotes || ""), active: r.active !== false, }); const saveStdJob = (rec) => { if (!canSetup()) return { ok: false, denied: true, msg: "Only an office manager or admin can maintain standard jobs." }; const j = normStdJob(rec || {}); if (!j.desc) return { ok: false, msg: "Description is required." }; if (!j.laborOp) return { ok: false, msg: "Labor op code is required." }; if (j.rateMode === "flat" && !(j.flat > 0)) return { ok: false, msg: "Flat price must be more than $0." }; if (j.rateMode === "rate" && !(j.estHours > 0)) return { ok: false, msg: "Estimated hours must be more than 0." }; if (j.rateMode === "rate" && j.rateSource === "custom" && !(j.rate > 0)) return { ok: false, msg: "Custom rate must be more than $0." }; let isNew = true; D._mutate((st) => { st.stdJobs = st.stdJobs || []; const i = st.stdJobs.findIndex(x => x.id === j.id); const rec2 = { ...j, updatedAt: nowIso(), updatedBy: me() }; if (i >= 0) { isNew = false; st.stdJobs[i] = { ...st.stdJobs[i], ...rec2 }; } else st.stdJobs.unshift({ ...rec2, created: nowIso() }); }); D._audit(isNew ? "Std Job Added" : "Std Job Updated", `${j.id} ${j.laborOp} — ${j.desc}`); return { ok: true, id: j.id, msg: `Standard job ${j.laborOp} ${isNew ? "added" : "saved"}.` }; }; const deleteStdJob = (id) => { if (!canSetup()) return { ok: false, denied: true, msg: "Only an office manager or admin can maintain standard jobs." }; let gone = null; D._mutate((st) => { const i = (st.stdJobs || []).findIndex(x => x.id === id); if (i >= 0) gone = st.stdJobs.splice(i, 1)[0]; }); if (!gone) return { ok: false, msg: "Standard job not found." }; D._audit("Std Job Deleted", `${id} ${gone.laborOp} — ${gone.desc}`); return { ok: true, msg: `Standard job ${gone.laborOp} deleted.` }; }; // Labor $ for a job: flat → flat; rate → hours × rate. // RC20 (fops): the job's labor $ is figured exactly like the server does (integer cents, half-up) — FopsCalc.jobLaborCents const jobLabor = (j) => window.FopsCalc ? window.FopsCalc.jobLaborCents({ rateMode: j.rateMode === "flat" ? "flat" : "rate", flat: j.flat, rate: j.rate || 0, hrs: j.hrs || 0, lab: j.lab }) / 100 : (j.rateMode === "flat" ? R2(j.flat) : R2((+j.hrs || 0) * (+j.rate || 0))); const rateFor = (payType) => { const s = svcSettings(); const pick = (id) => { const r = s.laborRates.find(x => x.id === id); return r ? +r.rate : null; }; // RC32 (r32fd, M3): the rate id per pay type is a setting (money.laborRates); its default is today's rule — Warranty WARR, // Internal INT, everything else SHOP, and the shop rate when that id is missing const map = (s.money && s.money.laborRates) || {}; return pick(map[payType] || "SHOP") || s.shopRate; }; const jobPartLine = (st, p, jobId) => { const inv = (st.parts || []).find(x => String(x.part).toUpperCase() === String(p.part || "").toUpperCase()); const qty = +p.qty || 1; if (inv) inv.committed = (inv.committed || 0) + qty; const price = p.price != null && p.price !== "" && +p.price > 0 ? R2(p.price) : (p.sale != null && +p.sale > 0 ? R2(p.sale) : (inv ? inv.list : 0)); return { id: D._uid("PL"), part: inv ? inv.part : (String(p.part || "").toUpperCase() || "MISC"), desc: p.desc || (inv ? inv.desc : "Part"), qty, cost: p.cost != null && p.cost !== "" ? R2(p.cost) : (inv ? inv.cost : 0), sale: price, ...(jobId != null ? { jobId } : {}), ...(inv && +inv.core > 0 ? { core: inv.core } : {}) }; // RC33 (mpx, B14): + the part's core charge (a customer-pay line collects it at the close) }; // COPY a template into the RO. Later RO edits never touch the template. const applyStdJob = (roId, stdJobId, opts) => { if (!canService()) return { ok: false, denied: true, msg: "Your role can't edit repair orders." }; opts = opts || {}; const tpl = (D.stdJobs() || []).find(x => x.id === stdJobId); if (!tpl) return { ok: false, msg: "Standard job not found." }; const t = JSON.parse(JSON.stringify(tpl)); // deep copy — never a reference let newId = null, err = null; D._mutate((st) => { const ro = findRO(st, roId); if (!ro) { err = "RO not found."; return; } if (ro.status === "Closed") { err = `${roId} is closed.`; return; } const payType = opts.payType || t.payType || "Customer"; const rate = t.rateMode === "rate" ? (t.rateSource === "custom" ? +t.rate : rateFor(payType)) : 0; const job = { id: D._uid("OP"), op: t.laborOp, desc: t.desc, custDesc: t.custDesc || "", hrs: +t.estHours || 0, rateMode: t.rateMode, rate, flat: +t.flat || 0, payType, stdJobId: t.id, techId: opts.techId !== undefined ? opts.techId : (ro.techId || ""), status: "PEND", approval: "pending", complaint: "", cause: "", correction: t.custDesc || "", techNotes: t.techNotes || "" }; job.lab = jobLabor(job); ro.ops = ro.ops || []; ro.ops.push(job); newId = job.id; ro.partsLines = ro.partsLines || []; (t.parts || []).forEach(p => ro.partsLines.push(jobPartLine(st, p, job.id))); if (!Array.isArray(ro.payments)) ro.payments = []; }); if (err) return { ok: false, msg: err }; D.recalcRO(roId); D._mutate(() => {}); D._audit("RO Std Job Applied", `${roId} ← ${t.laborOp} ${t.desc} (template ${t.id})`); touchRecon(roId, (opts && opts.reason) || `Added ${t.laborOp} ${t.desc}`); return { ok: true, id: newId, msg: `${t.laborOp} — ${t.desc} added to ${roId}.` }; }; const addROJob = (roId, job) => { if (!canService()) return { ok: false, denied: true, msg: "Your role can't edit repair orders." }; job = job || {}; const payType = job.payType || "Customer"; const j = { op: job.op || "LABOR", desc: job.desc || "Labor", hrs: +job.hrs || 0, rateMode: job.rateMode || "rate", rate: job.rate != null ? +job.rate : rateFor(payType), flat: +job.flat || 0, payType, techId: job.techId || "", custDesc: job.custDesc || "", complaint: job.complaint || "", status: "PEND" }; j.lab = jobLabor(j); const r = D.addROOp(roId, { ...j, id: D._uid("OP") }); if (r.ok) { D._mutate((st) => { const ro = findRO(st, roId); if (ro && !Array.isArray(ro.payments)) ro.payments = []; }); touchRecon(roId, `Added ${j.op} ${j.desc}`); } return r; }; const JOB_PRICE_KEYS = ["hrs", "rate", "flat", "rateMode"]; const updateROJob = (roId, jobId, patch) => { if (!canService()) return { ok: false, denied: true, msg: "Your role can't edit repair orders." }; const ro = (D.repairOrders() || []).find(r => r.id === roId); if (!ro) return { ok: false, msg: "RO not found." }; if (ro.status === "Closed") return { ok: false, msg: `${roId} is closed.` }; const cur = (ro.ops || []).find(o => String(o.id) === String(jobId)); if (!cur) return { ok: false, msg: "Job not found." }; const p = { ...patch }; if (JOB_PRICE_KEYS.some(k => k in p)) { const next = { ...cur, ...p }; if (!next.rateMode) next.rateMode = "rate"; if (next.rateMode === "rate" && (next.rate == null || next.rate === "" || (!cur.rateMode && !("rate" in p)))) next.rate = next.rate || rateFor(next.payType); p.rateMode = next.rateMode; p.rate = +next.rate || 0; p.flat = +next.flat || 0; p.lab = jobLabor(next); } const r = D.updateROOp(roId, cur.id, p); if (JOB_PRICE_KEYS.some(k => k in patch) || "payType" in patch) touchRecon(roId, patch.reason); return r; }; const removeROJob = (roId, jobId) => { if (!canService()) return { ok: false, denied: true, msg: "Your role can't edit repair orders." }; D._mutate((st) => { const ro = findRO(st, roId); if (!ro || ro.status === "Closed") return; (ro.partsLines || []).filter(l => String(l.jobId) === String(jobId)).forEach(l => { const p = (st.parts || []).find(x => x.part === l.part); if (p) p.committed = Math.max(0, (p.committed || 0) - l.qty); }); ro.partsLines = (ro.partsLines || []).filter(l => String(l.jobId) !== String(jobId)); ro.ops = (ro.ops || []).filter(o => String(o.id) !== String(jobId)); }); D.recalcRO(roId); D._mutate(() => {}); return { ok: true, msg: "Job removed." }; }; const addROJobPart = (roId, jobId, part) => { if (!canService()) return { ok: false, denied: true, msg: "Your role can't edit repair orders." }; let err = null; D._mutate((st) => { const ro = findRO(st, roId); if (!ro) { err = "RO not found."; return; } if (ro.status === "Closed") { err = `${roId} is closed.`; return; } ro.partsLines = ro.partsLines || []; ro.partsLines.push(jobPartLine(st, part || {}, jobId != null ? jobId : undefined)); }); if (err) return { ok: false, msg: err }; D.recalcRO(roId); D._mutate(() => {}); touchRecon(roId, `Added part ${(part && (part.part || part.desc)) || ""}`); return { ok: true, msg: `${(part && (part.part || part.desc)) || "Part"} added.` }; }; const updateROPartLine = (roId, lineId, patch) => { if (!canService()) return { ok: false, denied: true, msg: "Your role can't edit repair orders." }; D._mutate((st) => { const ro = findRO(st, roId); if (!ro || ro.status === "Closed") return; const l = (ro.partsLines || []).find(x => x.id === lineId); if (!l) return; if (patch.qty != null) { const q = Math.max(0, +patch.qty || 0); const p = (st.parts || []).find(x => x.part === l.part); if (p) p.committed = Math.max(0, (p.committed || 0) - l.qty + q); l.qty = q; } if (patch.sale != null) l.sale = R2(patch.sale); if (patch.desc != null) l.desc = String(patch.desc); ro.partsLines = ro.partsLines.filter(x => x.qty > 0); }); D.recalcRO(roId); D._mutate(() => {}); touchRecon(roId); return { ok: true }; }; // ── Sublet / outside work: ro.sublets [{id, vendor, desc, cost, sale, qty, jobId?}] ── const addROSublet = (roId, sub) => { if (!canService()) return { ok: false, denied: true, msg: "Your role can't edit repair orders." }; const x = sub || {}; if (!String(x.vendor || "").trim() || !String(x.desc || "").trim()) return { ok: false, msg: "Sublet needs a vendor and a description." }; if (!(+x.sale >= 0) || !(+x.cost >= 0)) return { ok: false, msg: "Enter the sublet cost and sale." }; let err = null; D._mutate((st) => { const ro = findRO(st, roId); if (!ro) { err = "RO not found."; return; } if (ro.status === "Closed") { err = `${roId} is closed.`; return; } ro.sublets = Array.isArray(ro.sublets) ? ro.sublets : []; ro.sublets.push({ id: D._uid("SB"), vendor: String(x.vendor).trim(), desc: String(x.desc).trim(), cost: R2(x.cost), sale: R2(x.sale), qty: +x.qty || 1, ...(x.jobId != null ? { jobId: x.jobId } : {}), po: String(x.po || "") }); }); if (err) return { ok: false, msg: err }; D.recalcRO(roId); D._mutate(() => {}); D._audit("RO Sublet", `${roId} + ${x.vendor} — ${x.desc} cost $${R2(x.cost).toFixed(2)} sale $${R2(x.sale).toFixed(2)}`); touchRecon(roId, `Sublet ${x.vendor} — ${x.desc}`); return { ok: true, msg: `Sublet ${x.vendor} added.` }; }; const updateROSublet = (roId, subId, patch) => { if (!canService()) return { ok: false, denied: true, msg: "Your role can't edit repair orders." }; D._mutate((st) => { const ro = findRO(st, roId); if (!ro || ro.status === "Closed") return; const x = (ro.sublets || []).find(y => y.id === subId); if (!x) return; ["cost", "sale"].forEach(k => { if (patch[k] != null) x[k] = R2(patch[k]); }); if (patch.qty != null) x.qty = Math.max(0, +patch.qty || 0); ["vendor", "desc", "po"].forEach(k => { if (patch[k] != null) x[k] = String(patch[k]); }); ro.sublets = ro.sublets.filter(y => (y.qty == null ? 1 : y.qty) > 0); }); D.recalcRO(roId); D._mutate(() => {}); touchRecon(roId); return { ok: true }; }; const removeROSublet = (roId, subId) => updateROSublet(roId, subId, { qty: 0 }); // ── Customer search + vehicles ────────────────────────────────────────── const custName = (c) => (window.CustNum && Array.isArray(c.names) && c.names.length > 1 ? window.CustNum.displayName(c) : "") || c.name || c.buyer || [c.lastName, c.firstName].filter(Boolean).join(", ") || c.custNum || ""; // RC30 (cn): a household shows every name const digits = (s) => String(s || "").replace(/\D/g, ""); const vehLabel = (v) => [v.yr, v.mk, v.mdl].filter(Boolean).join(" ") || v.label || ""; const customerVehicles = (custNum, name) => { const out = [], seen = new Set(); const add = (v, src) => { const key = (v.vin || vehLabel(v) || "").toUpperCase(); if (!key || seen.has(key)) return; seen.add(key); out.push({ vin: (v.vin || "").toUpperCase(), yr: v.yr ? String(v.yr) : "", mk: v.mk || "", mdl: v.mdl || "", miles: v.miles || "", stock: v.stock || "", label: v.label || "", src }); }; const c = custNum && (D.customers() || {})[custNum]; const nm = String(name || (c ? custName(c) : "")).toUpperCase(); if (c) { (Array.isArray(c.vehicles) ? c.vehicles : []).forEach(v => add(v, "Customer file")); if (c.vin || c.vehYr) add({ vin: c.vin, yr: c.vehYr || c.yr, mk: c.vehMk || c.mk, mdl: c.vehMdl || c.mdl, miles: c.miles }, "Customer file"); } (D.repairOrders() || []).forEach(r => { const hit = (custNum && r.custNum === custNum) || (nm && String(r.customer || "").toUpperCase() === nm); if (!hit) return; if (r.veh && (r.veh.vin || r.veh.mdl)) add(r.veh, "RO " + r.id); else if (r.vehicle || r.vin) { const m = String(r.vehicle || "").match(/^(\d{4})\s+(\S+)\s+(.*)$/); add({ vin: r.vin, yr: m ? m[1] : "", mk: m ? m[2] : "", mdl: m ? m[3] : "", label: r.vehicle }, "RO " + r.id); } }); (D.deals() || []).forEach(d => { const hit = (custNum && d.custNum === custNum) || (nm && String(d.buyer || "").toUpperCase() === nm); if (!hit) return; const u = d.stock && D._unit ? D._unit(d.stock) : null; if (u) add({ vin: u.vin, yr: u.yr, mk: u.mk, mdl: u.mdl, miles: u.miles, stock: u.stock }, "Deal " + d.id); else if (d.vehicle || d.vin) add({ vin: d.vin, label: typeof d.vehicle === "string" ? d.vehicle : "" }, "Deal " + d.id); }); return out; }; const customerSearch = (q) => { const s = String(q || "").trim().toLowerCase(); if (!s) return []; const dq = digits(s); const rows = Object.values(D.customers() || {}).map(c => ({ custNum: c.custNum, name: custName(c), phone: c.phone || c.cell || c.homePhone || "", email: c.email || "", // RC30 (cn): a customer # is not a phone city: c.city || "", c, })); return rows.filter(r => { if (D.custQueryHit && D.custQueryHit(q, r.custNum, [r.phone])) return true; // RC31 (fx31, F6): an old number / a phone in any format (one reading) if (r.name.toLowerCase().includes(s)) return true; if (r.email && r.email.toLowerCase().includes(s)) return true; if (String(r.custNum || "").toLowerCase().includes(s)) return true; if (dq.length >= 3 && (digits(r.phone).includes(dq) || digits(r.custNum).includes(dq))) return true; if (dq.length >= 7 && Array.isArray(r.c.aliases) && r.c.aliases.some((a) => digits(a).includes(dq))) return true; // RC30 (cn): an old number from an old printout if (Array.isArray(r.c.names) && r.c.names.some((n) => n && [n.first, n.last].filter(Boolean).join(" ").toLowerCase().includes(s))) return true; // every person on a household account const alt = [r.c.firstName, r.c.lastName].filter(Boolean).join(" ").toLowerCase(); return !!alt && alt.includes(s); }).slice(0, 50).map(r => ({ custNum: r.custNum, name: r.name, phone: r.phone, email: r.email, city: r.city, vehicles: customerVehicles(r.custNum, r.name) })); }; const setROCustomer = (roId, cust, veh) => { if (!canService()) return { ok: false, denied: true, msg: "Your role can't edit repair orders." }; let err = null; D._mutate((st) => { const ro = findRO(st, roId); if (!ro) { err = "RO not found."; return; } if (ro.status === "Closed") { err = `${roId} is closed.`; return; } if (cust) { ro.custNum = cust.custNum || ""; ro.cust = { custNum: cust.custNum || "", name: cust.name || "", phone: cust.phone || "", email: cust.email || "" }; ro.customer = cust.name || ro.customer; } if (veh) { ro.veh = { vin: (veh.vin || "").toUpperCase(), yr: veh.yr || "", mk: veh.mk || "", mdl: veh.mdl || "", miles: veh.miles || "", stock: veh.stock || "" }; ro.vehicle = vehLabel(veh) || veh.label || ro.vehicle; ro.vin = ro.veh.vin || ro.vin; if (veh.miles) ro.miles = veh.miles; } if (!Array.isArray(ro.payments)) ro.payments = []; }); if (err) return { ok: false, msg: err }; D._audit("RO Customer", `${roId} → ${cust ? cust.name + " (" + (cust.custNum || "") + ")" : ""}${veh ? " · " + (vehLabel(veh) || veh.vin) : ""}`); return { ok: true, msg: `${roId}: ${cust ? cust.name : ""}${veh ? " · " + (vehLabel(veh) || veh.vin) : ""}` }; }; const setROVehicleField = (roId, patch) => { if (!canService()) return { ok: false, denied: true, msg: "Your role can't edit repair orders." }; D._mutate((st) => { const ro = findRO(st, roId); if (!ro || ro.status === "Closed") return; ro.veh = { ...(ro.veh || {}), ...patch }; if (patch.miles != null) ro.miles = patch.miles; if (patch.vin != null) ro.vin = String(patch.vin).toUpperCase(); if (patch.yr != null || patch.mk != null || patch.mdl != null) ro.vehicle = vehLabel(ro.veh) || ro.vehicle; }); return { ok: true }; }; const setRONotes = (roId, patch) => { if (!canService()) return { ok: false, denied: true, msg: "Your role can't edit repair orders." }; D._mutate((st) => { const ro = findRO(st, roId); if (!ro) return; ro.notes = { ...(ro.notes || {}), ...patch }; if (patch.complaint != null) ro.concern = String(patch.complaint).split("\n")[0].slice(0, 140); }); return { ok: true }; }; const openServiceRO = (detail) => { if (!canService()) return { ok: false, denied: true, msg: "Your role can't open repair orders." }; const d = detail || {}; const r = D.createRO({ customer: d.cust ? d.cust.name : (d.customer || ""), vehicle: d.veh ? vehLabel(d.veh) : (d.vehicle || ""), vin: d.veh ? d.veh.vin : (d.vin || ""), concern: d.concern || "", techId: d.techId || "", payType: d.payType || "Customer", advisor: d.advisor, internalStock: d.internalStock || "" }); if (!r.ok) return r; D._mutate((st) => { const ro = findRO(st, r.id); if (!ro) return; ro.payments = []; ro.notes = { complaint: d.concern || "", diagnosis: "", workPerformed: "", customer: "", internal: "" }; if (d.cust) { ro.custNum = d.cust.custNum || ""; ro.cust = { custNum: d.cust.custNum || "", name: d.cust.name || "", phone: d.cust.phone || "", email: d.cust.email || "" }; } if (d.veh) { ro.veh = { vin: (d.veh.vin || "").toUpperCase(), yr: d.veh.yr || "", mk: d.veh.mk || "", mdl: d.veh.mdl || "", miles: d.miles || d.veh.miles || "", stock: d.veh.stock || "" }; ro.miles = ro.veh.miles; } ro.openedBy = me(); if (d.internalStock) { ro.recon = true; ro.payType = "Internal"; ro.internalStock = String(d.internalStock).toUpperCase(); ro.stock = ro.internalStock; } }); D._audit("RO Opened", `${r.id} — ${d.cust ? d.cust.name + " (" + (d.cust.custNum || "") + ")" : d.customer || ""}`); return r; }; // ── Totals / split-pay ────────────────────────────────────────────────── const cardFee = (amount) => { const s = svcSettings(); const pct = s.cardFeeOn ? +s.cardFeePct || 0 : 0; return { on: s.cardFeeOn, pct, fee: R2((+amount || 0) * pct / 100) }; }; const roTotals = (roId) => { const ro = D._roForRead ? D._roForRead(roId) : (D.repairOrders() || []).find(r => r.id === roId); // RC32 fix: + the RO itself / Setup's preview RO (look only) if (!ro) return null; const live = (ro.payments || []).filter(p => !p.voided); // RC20 (fops): the total (and the split by payer) is the ONE calculation every screen uses (DMS.roMoney → FopsCalc / the committed invoice) const money = D.roMoney ? D.roMoney(ro) : null; if (money) { const allocated = R2(live.reduce((s, p) => s + (+p.amount || 0), 0)); const fees = R2(live.reduce((s, p) => s + (+p.fee || 0), 0)); const byMethod = {}; live.forEach(p => { byMethod[p.method] = R2((byMethod[p.method] || 0) + (+p.amount || 0)); }); return { total: money.total, allocated, fees, balance: R2(money.total - allocated), customerBalance: money.customerBalance, collect: R2(allocated + fees), byPayType: money.byPayer, byMethod, live, money }; } const total = R2(ro.total != null ? ro.total : ro.laborSale + ro.partsSale + (ro.subletSale || 0) + (ro.tax || 0)); const allocated = R2(live.reduce((s, p) => s + (+p.amount || 0), 0)); const fees = R2(live.reduce((s, p) => s + (+p.fee || 0), 0)); const byPayType = {}; const partsBy = {}; const declined = D.roDeclinedJobIds ? D.roDeclinedJobIds(ro) : new Set(); (ro.partsLines || []).filter(l => !(l.jobId != null && declined.has(String(l.jobId)))).forEach(l => { const k = l.jobId != null && (ro.ops || []).some(o => String(o.id) === String(l.jobId)) ? String(l.jobId) : "_"; partsBy[k] = R2((partsBy[k] || 0) + (+l.sale || 0) * (+l.qty || 0)); }); (ro.ops || []).filter(o => !declined.has(String(o.id))).forEach(o => { const k = o.payType || ro.payType || "Customer"; byPayType[k] = R2((byPayType[k] || 0) + (+o.lab || 0) + (partsBy[String(o.id)] || 0)); }); (ro.sublets || []).filter(x => !(x.jobId != null && declined.has(String(x.jobId)))).forEach(x => { const k = x.jobId != null && (ro.ops || []).some(o => String(o.id) === String(x.jobId)) ? String(x.jobId) : "_"; partsBy[k] = R2((partsBy[k] || 0) + (+x.sale || 0) * (x.qty == null ? 1 : +x.qty || 0)); }); if (partsBy._) { const k = ro.payType || "Customer"; byPayType[k] = R2((byPayType[k] || 0) + partsBy._); } if (ro.tax) byPayType.Customer = R2((byPayType.Customer || 0) + ro.tax); const byMethod = {}; live.forEach(p => { byMethod[p.method] = R2((byMethod[p.method] || 0) + (+p.amount || 0)); }); return { total, allocated, fees, balance: R2(total - allocated), collect: R2(allocated + fees), byPayType, byMethod, live }; }; const splitCheck = (roId) => { const t = roTotals(roId); if (!t) return { ok: false, msgs: ["RO not found."] }; const msgs = []; if (!t.live.length) msgs.push("No payments recorded yet."); if (Math.abs(t.balance) > 0.009) msgs.push(t.balance > 0 ? `Balance due $${t.balance.toFixed(2)} — add a payment or split.` : `Over-allocated by $${(-t.balance).toFixed(2)}.`); return { ok: msgs.length === 0, ...t, msgs }; }; const uniqJe = (st, base) => { let id = base, n = 2; const has = (x) => (st.journalEntries || []).some(j => j.id === x); while (has(id)) id = base + "-" + (n++); return id; }; const roNum = (roId) => String(roId).replace(/^RO-?/i, ""); // GL lines for ONE payment posted on its own (after close): DR payer / CR A/R 220 (+fee). // RC28 (svc): a Sunbit payment — DR the Sunbit receivable for the amount; Sunbit's merchant fee DR the fee expense / CR the receivable const sunbitLines = (p, ro, after) => { const cfg = (D._svc && D._svc.config && D._svc.config.settings && D._svc.config.settings.sunbit) || {}; const rec = p.recAcct || cfg.recAcct || "294", feeAcct = p.feeAcct || cfg.feeAcct || "0775", amt = R2(p.amount); const mf = p.merchantFee !== undefined && p.merchantFee !== "" ? R2(p.merchantFee) : (cfg.feePct !== undefined && cfg.feePct !== "" ? R2(amt * (+cfg.feePct || 0) / 100) : 0); const lines = [{ acct: rec, dr: amt, cr: 0, desc: `Sunbit ${p.receiptNo || ""}` }]; if (after) lines.push({ acct: A().arCustomer, dr: 0, cr: amt, desc: `Applied to ${ro.id}` }); if (mf) { lines.push({ acct: feeAcct, dr: mf, cr: 0, desc: "Sunbit merchant fee" }); lines.push({ acct: rec, dr: 0, cr: mf, desc: "Sunbit merchant fee — kept from the deposit" }); } return lines; }; const payLinesAfterClose = (p, ro) => { if (p && p.method === "sunbit") return sunbitLines(p, ro, true); // RC28 (svc) const acct = D.roPayAcct(p, ro), ar = A().arCustomer, amt = R2(p.amount), fee = R2(p.fee); if (acct === ar) return []; const lines = [{ acct, dr: R2(amt + fee), cr: 0, desc: `${D.roPayLabel(p.method)} ${p.receiptNo}` }, { acct: ar, dr: 0, cr: amt, desc: `Applied to ${ro.id}` }]; if (fee) lines.push({ acct: A().cardFeeIncome, dr: 0, cr: fee, desc: "Card fee charged to customer" }); return lines; }; // What a payment will post / posted (for the Payments tab preview). const paymentGLPreview = (roId, p) => { const ro = D._roForRead ? D._roForRead(roId) : (D.repairOrders() || []).find(r => r.id === roId); if (!ro || !p) return []; // RC32 fix: look only (as roTotals) const amt = R2(p.amount), fee = R2(p.fee); if (ro.status === "Closed" || p.postedSeparately) return payLinesAfterClose({ ...p, receiptNo: p.receiptNo || "(new)" }, ro); if (p.method === "sunbit") return sunbitLines(p, ro, false); // RC28 (svc): posts with Close RO const lines = [{ acct: D.roPayAcct(p, ro), dr: R2(amt + fee), cr: 0, desc: D.roPayLabel(p.method) }]; if (fee) lines.push({ acct: A().cardFeeIncome, dr: 0, cr: fee, desc: "Card fee charged to customer" }); return lines; }; // RC24 (svcm): on a CLOSED RO a payment, a void and a refund are SERVER commands (POST /api/fixedops/ro/payment | /void | /refund): // the server checks the role, the open balance, computes the fee, issues the receipt number, posts PMT- / VD- / RF- (a check refund // through the check command) and writes the RO — all or nothing. These functions return a Promise then; on an OPEN RO they still // record locally (the payment posts with Close RO) and return the result at once, as before. const svcmCommand = async (path, body, prefix, label) => { if (!D._command) return { ok: false, msg: "This DMS cannot reach the server command — reload the DMS (Ctrl+F5)." }; const key = D._newIdemKey(prefix); const r = await D._command(path, body, key, "/ops/op/"); if (!r.ok) { const e = r.err; const b = (e && e.body) || {}; if (r.unknown) return { ok: false, unknown: true, key, msg: `The server did not answer. Before trying ${label} again, refresh the RO — if the Payments tab shows it, it was recorded. (The office can look up ${key}.)` }; if (b.record && b.rev != null && body.roId) D._applyServerRecords([{ collection: "repairOrders", id: body.roId, rev: b.rev, record: b.record }]); return { ok: false, key, code: b.code, msg: b.error || (e && e.message) || `The server refused ${label}.`, balanceCents: b.balanceCents, leftCents: b.leftCents }; } D._applyServerRecords(r.res.records); return { ok: true, key, res: r.res, recovered: !!r.recovered }; }; const recordROPaymentAfterClose = async (roId, p) => { const body = { roId, method: p.method, amount: p.amount, tendered: p.tendered !== "" ? p.tendered : undefined, drawerRef: p.drawerRef, ref: p.ref, last4: p.last4, cardBrand: p.cardBrand, account: p.account, dueDate: p.dueDate, provider: p.provider, claimNo: p.claimNo, approvedAmt: p.approvedAmt !== "" ? p.approvedAmt : undefined, note: p.note, stock: p.stock, sunbitRef: p.method === "sunbit" ? p.sunbitRef : undefined, merchantFee: p.method === "sunbit" && p.merchantFee !== "" ? p.merchantFee : undefined }; // RC28 (svc): + the Sunbit reference and merchant fee const r = await svcmCommand("/fixedops/ro/payment", body, "rp", "the payment"); if (!r.ok) return r; const res = r.res; D._audit("RO Payment", `${roId} ${res.receiptNo} ${D.roPayLabel(p.method)} $${((res.amountCents || 0) / 100).toFixed(2)}${res.feeCents ? " + card fee $" + (res.feeCents / 100).toFixed(2) : ""}${res.jeId ? " → " + res.jeId : ""}${r.recovered ? " (answer recovered after a dropped connection)" : ""}`); return { ok: true, id: res.payment && res.payment.id, receiptNo: res.receiptNo, jeId: res.jeId, fee: (res.feeCents || 0) / 100, balance: (res.balanceCents || 0) / 100, msg: res.message || `${D.roPayLabel(p.method)} recorded (${res.receiptNo}).`, warnings: res.warnings || [] }; }; const recordROPayment = (roId, input) => { if (!canService()) return { ok: false, denied: true, msg: "Your role can't take payments on repair orders." }; const ro0 = (D.repairOrders() || []).find(r => r.id === roId); if (!ro0) return { ok: false, msg: "RO not found." }; const p = { ...(input || {}) }; const method = String(p.method || ""); if (!D.RO_PAY_METHODS.includes(method)) return { ok: false, msg: "Pick how this portion is paid." }; for (const k of Object.keys(p)) if (k !== "amount" && typeof p[k] === "string" && looksLikePAN(p[k])) return { ok: false, msg: "That looks like a full card number. Never enter card numbers here — take the card on the terminal, then record only the processor reference (and last 4 if you like)." }; if (p.cvv || p.cvc || p.securityCode || p.cardNumber || p.pan) return { ok: false, msg: "Card numbers and security codes are never stored. Record the processor reference only." }; const closed = ro0.status === "Closed"; // RC28 (svc): a Sunbit payment is recorded by the SERVER — after the close (POST /api/fixedops/ro/payment) or WITH the close (POST /api/ro/close): // on an open RO it closes and posts the RO with the Sunbit payment (the Payments tab asks first). Never kept on the station. if (method === "sunbit" && !closed) return D.closeRO(roId, { payments: [{ method: "sunbit", amount: String(p.amount), sunbitRef: String(p.sunbitRef || ""), merchantFee: p.merchantFee !== undefined && p.merchantFee !== "" ? String(p.merchantFee) : undefined }] }); if (closed && !["cash", "card", "sunbit"].includes(method) && !canPostChange()) return { ok: false, denied: true, msg: "This RO is posted — changing who pays needs an office manager or admin." }; if (closed) return recordROPaymentAfterClose(roId, p); // RC24: the server records it (a Promise) let amount = R2(p.amount); if (method === "contract" && !(amount > 0) && +p.approvedAmt > 0) amount = R2(p.approvedAmt); if (!(amount > 0)) return { ok: false, msg: "Enter an amount more than $0." }; const t = roTotals(roId); if (amount > t.balance + 0.005) return { ok: false, msg: `Only $${Math.max(0, t.balance).toFixed(2)} is still unpaid on ${roId}. Enter that or less (split the rest).` }; const rec = { id: D._uid("PAY"), method, amount, fee: 0, at: nowIso(), by: me() }; if (method === "cash") { rec.tendered = p.tendered != null && p.tendered !== "" ? R2(p.tendered) : amount; if (rec.tendered < amount) return { ok: false, msg: "Cash received is less than the amount applied." }; rec.change = R2(rec.tendered - amount); rec.drawerRef = String(p.drawerRef || "").trim(); if (!rec.drawerRef) return { ok: false, msg: "Enter the cash drawer or deposit reference." }; } if (method === "card") { const f = cardFee(amount); rec.fee = f.fee; rec.feePct = f.pct; rec.ref = String(p.ref || "").trim(); rec.last4 = String(p.last4 || "").trim(); if (!rec.ref) return { ok: false, msg: "Enter the processor reference / auth code from the terminal." }; if (rec.last4 && !/^\d{4}$/.test(rec.last4)) return { ok: false, msg: "Last 4 must be exactly 4 digits (or leave it blank)." }; rec.cardBrand = String(p.cardBrand || "").trim(); } if (method === "invoice") { const ac = (D.arCustomers ? D.arCustomers() : []).find(c => c.acct === String(p.account || "")); if (!ac) return { ok: false, msg: "Pick the A/R account to invoice." }; rec.account = ac.acct; rec.acctName = ac.name; const days = parseInt(String(ac.terms || "Net 30").replace(/\D/g, ""), 10) || 30; rec.dueDate = p.dueDate || new Date(Date.now() + days * 86400000).toISOString().slice(0, 10); rec.openBalance = R2((ac.txns || []).reduce((s, x) => s + (+x.charge || 0) - (+x.credit || 0), 0)); } if (method === "warranty") { rec.provider = String(p.provider || "").trim(); rec.claimNo = String(p.claimNo || "").trim(); if (!rec.provider || !rec.claimNo) return { ok: false, msg: "Warranty needs the responsible provider and the claim / reference number." }; } if (method === "contract") { rec.provider = String(p.provider || "").trim(); rec.claimNo = String(p.claimNo || "").trim(); rec.approvedAmt = R2(p.approvedAmt != null && p.approvedAmt !== "" ? p.approvedAmt : amount); rec.customerResp = R2(p.customerResp); if (!rec.provider || !rec.claimNo) return { ok: false, msg: "Service contract needs the provider and the authorization / claim number." }; if (amount > rec.approvedAmt + 0.005) return { ok: false, msg: `The contract approved $${rec.approvedAmt.toFixed(2)} — you can't apply more than that.` }; } if (method === "courtesy") { rec.note = String(p.note || "").trim(); if (!rec.note) return { ok: false, msg: "Give the reason for the courtesy (it's charged to service gross)." }; } if (method === "internal") { rec.stock = String(p.stock || ro0.internalStock || "").trim().toUpperCase(); if (!rec.stock) return { ok: false, msg: "Internal pay needs the stock # it's charged to." }; } if (p.note && !rec.note) rec.note = String(p.note); let jeId = null; D._mutate((st) => { const ro = findRO(st, roId); if (!ro) return; if (!Array.isArray(ro.payments)) ro.payments = []; rec.receiptNo = `RC-${roNum(roId)}-${ro.payments.length + 1}`; if (closed) { rec.posted = true; rec.postedSeparately = true; rec.postedAt = nowIso(); if (payLinesAfterClose(rec, ro).length) { jeId = uniqJe(st, `PMT-${roNum(roId)}`); rec.jeId = jeId; } if (method === "warranty") { st.warrantyReceivables = st.warrantyReceivables || []; st.warrantyReceivables.unshift({ claim: rec.claimNo, ro: ro.id, amount: rec.amount, provider: rec.provider, payId: rec.id, submitted: false, status: "Not Submitted", closed: nowIso() }); } if (method === "invoice") { const c = (st.arCustomers || []).find(x => x.acct === rec.account); if (c) { c.txns = c.txns || []; c.txns.push({ id: "T-" + rec.id, date: nowIso(), kind: "INV", ref: ro.id, desc: `Repair order ${ro.id}`, charge: rec.amount, credit: 0, due: rec.dueDate, payId: rec.id }); } } } ro.payments.push(rec); if (closed && method === "internal") syncReconPosted(st, ro); }); if (jeId) { const ro = (D.repairOrders() || []).find(r => r.id === roId); D._postGL({ id: jeId, date: fmtD(new Date()), src: "Service", ro: roId, jrn: 30, ref: rec.receiptNo, desc: `${roId} — payment ${rec.receiptNo} (${D.roPayLabel(method)})${!["cash", "card"].includes(method) ? " · pay type change after posting" : ""}`, lines: payLinesAfterClose(rec, ro) }); } D._audit(closed && !["cash", "card"].includes(method) ? "RO Pay Type Change" : "RO Payment", `${roId} ${rec.receiptNo} ${D.roPayLabel(method)} $${amount.toFixed(2)}${rec.fee ? " + card fee $" + rec.fee.toFixed(2) : ""}${rec.claimNo ? " claim " + rec.claimNo : ""}${rec.ref ? " ref " + rec.ref : ""}${jeId ? " → " + jeId : closed ? "" : " (posts at close)"}`); return { ok: true, id: rec.id, receiptNo: rec.receiptNo, jeId, fee: rec.fee, msg: `${D.roPayLabel(method)} $${amount.toFixed(2)}${rec.fee ? ` + $${rec.fee.toFixed(2)} card fee` : ""} recorded (${rec.receiptNo})${rec.change ? ` — change due $${rec.change.toFixed(2)}` : ""}.` }; }; // Reverse the GL effect of one posted payment (in the close entry or its own). const reversalLines = (p, ro, amt, fee, tag) => { const acct = D.roPayAcct(p, ro), ar = A().arCustomer; if (acct === ar) return []; const lines = [{ acct: ar, dr: R2(amt), cr: 0, desc: `${tag} ${p.receiptNo} — back to customer A/R` }]; if (fee) lines.push({ acct: A().cardFeeIncome, dr: R2(fee), cr: 0, desc: `${tag} card fee` }); lines.push({ acct, dr: 0, cr: R2(amt + fee), desc: `${tag} ${D.roPayLabel(p.method)} ${p.receiptNo}` }); return lines; }; const voidROPayment = (roId, payId, reason) => { const ro0 = (D.repairOrders() || []).find(r => r.id === roId); if (!ro0) return { ok: false, msg: "RO not found." }; const p0 = (ro0.payments || []).find(x => x.id === payId); if (!p0) return { ok: false, msg: "Payment not found." }; if (p0.voided) return { ok: false, msg: `${p0.receiptNo} is already void.` }; if (p0.refundOf) return { ok: false, msg: "A refund can't be voided — record a new payment instead." }; if (!String(reason || "").trim()) return { ok: false, msg: "A reason is required to void a payment." }; const posted = !!p0.posted || ro0.status === "Closed"; if (posted ? !canPostChange() : !canService()) return { ok: false, denied: true, msg: posted ? "Voiding a posted payment needs an office manager or admin." : "Your role can't void payments." }; if (posted && (ro0.payments || []).some(x => x.refundOf === payId && !x.voided)) return { ok: false, msg: "This payment was partly refunded — void isn't allowed; refund the rest instead." }; if (ro0.status === "Closed") { // RC24: the server voids it (a linked reversing entry) — a Promise return svcmCommand("/fixedops/ro/payment/void", { roId, receiptNo: p0.receiptNo, reason: String(reason).trim() }, "rv", "the void").then(r => { if (!r.ok) return r; D._audit("RO Payment Void", `${roId} ${p0.receiptNo} ${D.roPayLabel(p0.method)} $${(+p0.amount).toFixed(2)} · ${reason}${r.res.jeId ? " → " + r.res.jeId : ""}`); return { ok: true, jeId: r.res.jeId, balance: (r.res.balanceCents || 0) / 100, msg: r.res.message || `${p0.receiptNo} voided.` }; }); } let jeId = null, lines = []; D._mutate((st) => { const ro = findRO(st, roId); const p = ro && (ro.payments || []).find(x => x.id === payId); if (!p) return; if (posted) { lines = reversalLines(p, ro, +p.amount, +p.fee || 0, "VOID"); if (lines.length) { jeId = uniqJe(st, `VD-${roNum(roId)}-${String(p.receiptNo).split("-").pop()}`); p.voidJeId = jeId; } if (p.method === "warranty") (st.warrantyReceivables || []).filter(w => w.ro === roId && (w.payId === p.id || w.claim === p.claimNo)).forEach(w => { w.status = "Voided"; w.voided = true; w.paid = true; w.amount = 0; }); if (p.method === "invoice") { const c = (st.arCustomers || []).find(x => x.acct === p.account); if (c) { c.txns = c.txns || []; c.txns.push({ id: "T-V" + p.id, date: nowIso(), kind: "CR", ref: roId, desc: `Void ${p.receiptNo} — ${reason}`, charge: 0, credit: +p.amount, payId: p.id }); } } } p.voided = true; p.voidReason = String(reason).trim(); p.voidedBy = me(); p.voidedAt = nowIso(); if (posted && p.method === "internal") syncReconPosted(st, ro); }); if (jeId) D._postGL({ id: jeId, date: fmtD(new Date()), src: "Service", ro: roId, jrn: 30, ref: p0.receiptNo, reversesPart: p0.jeId || "", desc: `${roId} — VOID payment ${p0.receiptNo} (${D.roPayLabel(p0.method)}) · ${reason}`, lines }); D._audit("RO Payment Void", `${roId} ${p0.receiptNo} ${D.roPayLabel(p0.method)} $${(+p0.amount).toFixed(2)} · ${reason}${jeId ? " → " + jeId : posted ? "" : " (not yet posted — no GL)"}`); return { ok: true, jeId, msg: `${p0.receiptNo} voided${jeId ? " — reversing entry " + jeId + " posted" : ""}.` }; }; const refundROPayment = (roId, payId, amount, reason, opts) => { if (!canPostChange()) return { ok: false, denied: true, msg: "Refunds need an office manager or admin." }; const ro0 = (D.repairOrders() || []).find(r => r.id === roId); const p0 = ro0 && (ro0.payments || []).find(x => x.id === payId); if (!p0) return { ok: false, msg: "Payment not found." }; if (!["cash", "card"].includes(p0.method) || p0.refundOf) return { ok: false, msg: "Only cash and card payments are refunded — use Void / change pay type for the others." }; if (!p0.posted) return { ok: false, msg: "This payment isn't posted yet — void it instead." }; if (p0.voided) return { ok: false, msg: "This payment is void." }; if (!String(reason || "").trim()) return { ok: false, msg: "A reason is required for a refund." }; if (ro0.status === "Closed") { // RC24: the server refunds it — cash / card back, or a CHECK through the check command (a Promise) const method = (opts && opts.method) || p0.method; return svcmCommand("/fixedops/ro/payment/refund", { roId, receiptNo: p0.receiptNo, amount: String(amount), method, reason: String(reason).trim(), ref: opts && opts.ref ? String(opts.ref) : undefined }, "rf", "the refund").then(r => { if (!r.ok) return r; D._audit("RO Refund", `${roId} ${r.res.receiptNo} refund of ${p0.receiptNo} $${((r.res.amountCents || 0) / 100).toFixed(2)}${r.res.feeCents ? " (+ fee $" + (r.res.feeCents / 100).toFixed(2) + ")" : ""} by ${method}${r.res.check ? " check #" + r.res.check.num : ""} · ${reason} → ${r.res.jeId}`); return { ok: true, jeId: r.res.jeId, receiptNo: r.res.receiptNo, check: r.res.check || null, balance: (r.res.balanceCents || 0) / 100, msg: r.res.message || `Refunded on ${p0.receiptNo} — ${r.res.jeId} posted.` }; }); } const already = R2((ro0.payments || []).filter(x => x.refundOf === payId && !x.voided).reduce((s, x) => s - (+x.amount || 0), 0)); const left = R2(p0.amount - already); const amt = R2(amount); if (!(amt > 0) || amt > left + 0.005) return { ok: false, msg: `Refund must be between $0.01 and $${left.toFixed(2)}.` }; const feePart = p0.fee ? R2(p0.fee * amt / p0.amount) : 0; let jeId = null, rec = null; D._mutate((st) => { const ro = findRO(st, roId); if (!ro) return; jeId = uniqJe(st, `RF-${roNum(roId)}-${String(p0.receiptNo).split("-").pop()}`); rec = { id: D._uid("PAY"), method: p0.method, amount: -amt, fee: -feePart, refundOf: payId, at: nowIso(), by: me(), note: String(reason).trim(), receiptNo: `RC-${roNum(roId)}-${ro.payments.length + 1}`, ref: p0.ref || "", last4: p0.last4 || "", posted: true, postedSeparately: true, jeId }; ro.payments.push(rec); }); const ro = (D.repairOrders() || []).find(r => r.id === roId); D._postGL({ id: jeId, date: fmtD(new Date()), src: "Service", ro: roId, jrn: 30, ref: rec.receiptNo, desc: `${roId} — REFUND of ${p0.receiptNo} (${D.roPayLabel(p0.method)}) · ${reason}`, lines: reversalLines(p0, ro, amt, feePart, "REFUND") }); D._audit("RO Refund", `${roId} ${rec.receiptNo} refund of ${p0.receiptNo} $${amt.toFixed(2)}${feePart ? " (+ fee $" + feePart.toFixed(2) + ")" : ""} · ${reason} → ${jeId}`); return { ok: true, jeId, msg: `Refunded $${R2(amt + feePart).toFixed(2)} on ${p0.receiptNo} — ${jeId} posted. ${roId} now shows $${amt.toFixed(2)} due.` }; }; // Change who pays: void the old line and record the new one (both audited). const changeROPayType = (roId, payId, next, reason) => { const ro0 = (D.repairOrders() || []).find(r => r.id === roId); const p0 = ro0 && (ro0.payments || []).find(x => x.id === payId); if (!p0) return { ok: false, msg: "Payment not found." }; if ((p0.posted || ro0.status === "Closed") && !canPostChange()) return { ok: false, denied: true, msg: "Changing the pay type on a posted RO needs an office manager or admin." }; if (!String(reason || "").trim()) return { ok: false, msg: "A reason is required to change the pay type." }; if (ro0.status === "Closed") { // RC24: two server commands, one after the other (the void first; the new line only after it committed) return Promise.resolve(voidROPayment(roId, payId, "Pay type change: " + reason)).then(async (v) => { if (!v.ok) return v; const r = await recordROPayment(roId, { amount: p0.amount, ...next }); D._audit("RO Pay Type Change", `${roId} ${p0.receiptNo} ${D.roPayLabel(p0.method)} → ${D.roPayLabel(next && next.method)} · ${reason} · ${r.ok ? "new " + r.receiptNo : "new line refused: " + r.msg}`); return r.ok ? { ...r, msg: `${p0.receiptNo} → ${D.roPayLabel(next.method)} (${r.receiptNo}). ${v.jeId ? "Reversal " + v.jeId + ". " : ""}${r.jeId ? "Posted " + r.jeId + "." : ""}` } : { ok: false, msg: `Old line voided but the new one was refused: ${r.msg} — record it on the Payments tab.` }; }); } const v = voidROPayment(roId, payId, "Pay type change: " + reason); if (!v.ok) return v; const r = recordROPayment(roId, { amount: p0.amount, ...next }); D._audit("RO Pay Type Change", `${roId} ${p0.receiptNo} ${D.roPayLabel(p0.method)} → ${D.roPayLabel(next && next.method)} · ${reason} · ${r.ok ? "new " + r.receiptNo : "new line refused: " + r.msg}`); return r.ok ? { ...r, msg: `${p0.receiptNo} → ${D.roPayLabel(next.method)} (${r.receiptNo}). ${v.jeId ? "Reversal " + v.jeId + ". " : ""}${r.jeId ? "Posted " + r.jeId + "." : ""}` } : { ok: false, msg: `Old line voided but the new one was refused: ${r.msg} — record it on the Payments tab.` }; }; const roJournalEntries = (roId) => { const st = String(roId).replace("RO", "ST"); const re = new RegExp("(^|[^0-9A-Z-])" + String(roId).replace(/-/g, "\\-") + "(?![0-9])"); return (D.journalEntries() || []).filter(j => j.ro === roId || j.id === st || re.test(String(j.desc || ""))); }; Object.assign(window.DMS, { SVC_DEFAULTS, SVC_JOB_PAY_TYPES: JOB_PAY_TYPES, svcSettings, saveServiceSettings, svcCanSetup: canSetup, svcCanPostChange: canPostChange, saveStdJob, deleteStdJob, applyStdJob, svcRateFor: rateFor, svcJobLabor: jobLabor, addROJob, updateROJob, removeROJob, addROJobPart, updateROPartLine, customerSearch, customerVehicles, setROCustomer, setROVehicleField, setRONotes, openServiceRO, addROSublet, updateROSublet, removeROSublet, roIsRecon: isRecon, roTouchRecon: touchRecon, roCardFee: cardFee, roTotals, roSplitCheck: splitCheck, svcLooksLikePAN: looksLikePAN, roPaymentGLPreview: paymentGLPreview, recordROPayment, voidROPayment, refundROPayment, changeROPayType, roJournalEntries, // RC32 (r32fd): the RO screens' settings + step helpers — work/r32fd/INTERFACE.md is the contract SVC_RO_TODAY, SVC_RO_DEFAULTS, SVC_STEP_GROUPS: STEP_GROUPS, SVC_MYVIEW_DEFAULTS: MYVIEW_DEFAULTS, SVC_TODAY_ADVISORS: ADVISORS_TODAY, SVC_SETTING_LEAVES: LEAVES.map(L => ({ path: L.path, id: L.id, who: L.who, label: L.label, later: L.later, today: r32Clone(L.today), def: r32Clone(L.def) })), SVC_SETTING_WHO_LEGACY: { ...LEGACY_WHO }, svcResolveSettings: resolveSettings, svcCanEditSetting: (path) => { const L = LEAF_BY_PATH[path]; return L ? !L.later && canWho(L.who) : LEGACY_WHO[path] ? canWho(LEGACY_WHO[path]) : false; }, svcSteps, svcStepsInUse, svcStepGroup, svcOkState, svcNextStep, svcMovePlan, svcMoveTo, svcAutoStep, svcAutoMove, svcLayout, svcCloseSpot, svcCloseChecklist, svcAdvisors, svcRoleKey, svcRoleView, svcDefaultPayType, svcDeviceType, svcMyView, svcMyViewSave, svcMyViewReset, }); })();