/* eslint-disable */ /* © 2026 K2B NETWORK SOLUTIONS LLC. All rights reserved. Proprietary and confidential. Unauthorized copying, distribution, or use of this file, via any medium, is strictly prohibited. Original work product. */ // JBREWS DMS — DMSCore. // The integration backbone. One shared dealership ledger that every module // reads from and writes to, plus the cascade engine that makes one action // (post a deal, close an RO) create the correct downstream results across // Sales, F&I, Inventory, Parts, Service, Accounting, and Payroll. // // Everything persists to localStorage (prototype). The GL postings flow // through the same JEStore the Accounting screen reads, so a posted deal // shows up in Journal Entries immediately. // // window.DMS is the singleton. Subscribe with DMS.subscribe(fn) to re-render. const DMS_KEY_BASE = "jbrews-dms-core-v3"; // ── RC35 (brd): this PC's copy of the store's data is kept PER STORE: "-s" = the store this page loaded // (K2BStore.pageId(), StoreBoot.js.txt - which also moved the RC34 copy "-d" into the primary store once). // A page that does not know its store yet (never signed in to RC35) keeps using the RC34 copy until its first sign-in (SPEC 4.2). const JB_LEGACY_BANK = (() => { try { return (JSON.parse(localStorage.getItem("jbrews-auth-config-v1") || "{}").dealer) || "489"; } catch (e) { return "489"; } })(); const dmsKey = () => { const s = window.K2BStore && window.K2BStore.pageId(); return s ? DMS_KEY_BASE + "-s" + s : DMS_KEY_BASE + "-d" + JB_LEGACY_BANK; }; // H11 (sales features): whose saved copy dmsKey() is (the signed-in user id). const dmsWhoKey = () => dmsKey() + "-who"; try { if (!(window.K2BStore && window.K2BStore.pageId()) && !localStorage.getItem(dmsKey()) && localStorage.getItem(DMS_KEY_BASE)) { localStorage.setItem(dmsKey(), localStorage.getItem(DMS_KEY_BASE)); // a PC from before the storage banks (RC34 behaviour) localStorage.removeItem(DMS_KEY_BASE); } } catch (e) {} // Migrate: drop old keys so testers start on the current (blank) data model. try { localStorage.removeItem("jbrews-dms-core-v1"); localStorage.removeItem("jbrews-dms-core-v2"); } catch (e) {} // ── Server wiring (JBREWS DMS v2) ────────────────────────────────────── // nginx proxies /api/ → localhost:3001 on the Lightsail host. Same origin. const API_BASE = (typeof location !== "undefined" ? location.origin : "") + "/api"; // ── (ldg, owner-reviewer spec 16.1) the page's protocol number. The server says its own and the minimum it accepts on // /api/health and on every answer; a page below the minimum gets 426 on financial writes and commands and reloads itself // ONCE (dmsStaleReload); a reload that still lands on an old page shows "Reload the DMS (Ctrl+F5)" instead of looping. ── const DMS_PROTOCOL = 34; // RC37 (tpl37): 34 = the first-run setup of a new dealership ("Set up your dealership", FirstRun.jsx.txt -> POST /api/setup) — an RC36 page (33) reloads; RC37 (forms37): 34 = the dealership forms (K2BForms.jsx.txt) read the store's form settings; RC36 was 33: RC36 (rov36): 33 = Void RO / Reverse closed RO are server commands (RoVoid.jsx.txt) — an RC35 page (32) reloads; RC36 (fpd36): 33 = the Floorplan screen + Dead Money's not-floored option; RC36 (imp36): 33 = the import review sends the tie-out entries by import (gl-tie) — an RC35 page (32) reloads; RC36 (security + payroll, lane sec36): 33 = the Payroll Tax screen reads the employee list from the server (office only, SSNs masked), the sign-in wait answer is shown, "Start over" is the owner's — an RC35 page (32) reloads; RC35 (the store switch): 32 = every request carries the store the page shows (X-K2B-Store), the session token carries the active store, POST /api/session/store switches it, the Owner Panel has the Stores card and every printout takes the store header from K2BPrint (PrintKit.jsx.txt) — an RC34 page (31) reloads and can never open a second store; RC34 (the Jared helper): 31 = the helper is docked in the top bar and asks the server (POST /api/helper/ask, the owner's daily cap) — an RC33 page (30) reloads; RC33 (money paths): 30 = stock-in / cost adjust / floorplan payoff / warranty pay + chargeback / factory + holdback receipt / A/R / bills / tax deposits / payroll / body-shop money after close / deal checks / lender refund / posted-deal correction / special-order deposits are SERVER commands (MoneyCore.jsx.txt) — an RC32 page (29) reloads; RC33 (svi): 30 = the service lists' names + the service tie-outs on the import review; RC32 (RO screens): 29 = service settings judged by group + audited, technician view only, authorization.estimate, technicians[].userId — an RC31 page (28) reloads; RC31 (import fixes, lane fx31): 28 = the search boxes share DMS.custNumsFor / custQueryHit (an old 10-digit number or a phone finds the account), the import review says when a file is dated before entries on the books, the customer-number planner names cust_not_issued / cust_code_word — an RC30 page (27) is told to reload; RC30 and earlier (27 … 1): see docs/k2b/api-operations.md, each release's "protocol" line (RC32: the history is kept out of this file — the in-browser compiler slows down past 500,000 characters). const WS_URL = (typeof location !== "undefined" ? (location.origin.replace(/^http/, "ws")) + "/ws" : ""); const AUTH_TOKEN_KEY = "jbrews-auth-token"; let AUTH_TOKEN = null; // Per-TAB copy first (sessionStorage survives a reload of THIS tab only), so // two people signed in on two tabs of one PC each keep their own identity // across a refresh. A brand-new tab falls back to the PC's last sign-in. try { AUTH_TOKEN = sessionStorage.getItem(AUTH_TOKEN_KEY) || localStorage.getItem(AUTH_TOKEN_KEY) || null; } catch (e) {} try { window.K2BStore && window.K2BStore._setTokenGetter(() => AUTH_TOKEN); window.K2BStore && window.K2BStore._setProtocol(DMS_PROTOCOL); } catch (e) {} // RC35 (brd): K2BStore.id() = THIS tab's token's store // ── RC22 (own): who this login is to the OWNER feature — is it the owner, which rights the owner gave / took away (from the sign-in // answer and every /api/bootstrap `me`). The SERVER enforces all of it; the page only shows the right menus and buttons. ── let dmsOwnMe = { owner: false, grants: { grant: [], revoke: [] } }; function dmsOwnSetMe(me) { const g = (me && me.grants && typeof me.grants === "object") ? me.grants : {}; const next = { owner: !!(me && me.owner), grants: { grant: Array.isArray(g.grant) ? g.grant.slice() : [], revoke: Array.isArray(g.revoke) ? g.revoke.slice() : [] } }; const changed = JSON.stringify(next) !== JSON.stringify(dmsOwnMe); dmsOwnMe = next; if (changed) { try { window.dispatchEvent(new CustomEvent("k2b-me", { detail: next })); } catch (e) {} } } /** a capability after the owner's per-user grants: a revoked right is gone, a granted one counts (base = the role's own) */ function dmsOwnGrantCan(cap, base, role) { if (!role) return base; const g = dmsOwnMe.grants || {}; if (Array.isArray(g.revoke) && g.revoke.indexOf(cap) !== -1) return false; if (Array.isArray(g.grant) && g.grant.indexOf(cap) !== -1) return true; return base; } const setAuthToken = (t) => { AUTH_TOKEN = t || null; if (!t) dmsOwnSetMe(null); // RC22 (own): signed off — nobody is the owner on this page try { if (t) { localStorage.setItem(AUTH_TOKEN_KEY, t); sessionStorage.setItem(AUTH_TOKEN_KEY, t); } else { localStorage.removeItem(AUTH_TOKEN_KEY); sessionStorage.removeItem(AUTH_TOKEN_KEY); } } catch (e) {} }; // All collections the server understands — must match server-v2/src/lib/collections.js. const SERVER_COLLECTIONS = { deals: { idField: "id" }, inventory: { idField: "stock" }, parts: { idField: "part" }, partsOrders: { idField: "po" }, cores: { idField: "id" }, repairOrders: { idField: "id" }, appointments: { idField: "id" }, technicians: { idField: "id" }, arCustomers: { idField: "acct" }, appraisals: { idField: "id" }, payables: { idField: "id" }, factoryReceivables: { idField: "id" }, bodyShopROs: { idField: "id" }, vendors: { idField: "id" }, // (ldg, lead decision 7) a check written from RC16 on is stored under its bank-scoped id '-' (data.id); older // records keep their number as the id. Every lookup goes by (bankId, num) — DMS.findCheck — whatever the form. checks: { idField: "num", idOf: (r) => (r.id != null && r.id !== "" ? r.id : r.num) }, bulkFluids: { idField: "id" }, employees: { idField: "id" }, // NOTE: no "users" collection. Server accounts are managed ONLY through // /api/team (teamCreate / teamUpdate / teamSetPassword); the local // Users & Logins registry is never mirrored to the server (C-02c). crmLeads: { idField: "id" }, deskScenarios: { idField: "id" }, policyAdjustments: { idField: "id" }, floorplanPayoffsDue: { idField: "stock" }, warrantyReceivables: { idField: "ro", idOf: (r) => (r.id != null && r.id !== "" ? r.id : r.ro) }, // RC24 (svcm): a second warranty claim after the close is its own record (RO-n-C2 …, carries id); the RO's first keeps the RO number lenderSubmissions: { idField: "id" }, commissions: { idField: "id" }, glAccounts: { idField: "acct" }, journalEntries: { idField: "id" }, audit: { idField: "id" }, arAccountTypes: { idField: "code" }, arTaxCodes: { idField: "code" }, arDunningMessages: { idField: "bucket" }, arLetterTemplates: { idField: "code" }, arLetters: { idField: "id" }, // customers is a MAP keyed by custNum in state ({ [custNum]: rec }) — every // screen reads it that way. The server still exchanges it as a list of // records; the sync layer converts at the edge (R-2/R-6). Storing it as an // Array made 10-digit phone custNums non-index props (never flushed) and // 7-digit ones a multi-million-slot sparse array. RC30 (cn): the key IS a // 7-digit number now — this collection must NEVER become an Array. customers: { idField: "custNum", map: true }, bankRec: { singleton: true }, arDefaults: { singleton: true }, acctPeriod: { singleton: true }, // service / imports / messaging (added with the service + integrations build) stdJobs: { idField: "id" }, importHistory: { idField: "id" }, importTemplates: { idField: "id" }, msgConsents: { idField: "id" }, messageLog: { idField: "id" }, serviceSettings: { singleton: true }, // check writing: bank accounts, check stock, MICR + the shared check-number sequence checkConfig: { singleton: true }, }; // List → map for map-type collections (server array → state object). const _colToMap = (rows, key) => { const m = {}; const src = Array.isArray(rows) ? rows : (rows && typeof rows === "object" ? Object.values(rows) : []); for (const r of src) { if (r && typeof r === "object" && r[key] != null && r[key] !== "") m[String(r[key])] = r; } return m; }; // State map (or a legacy array) → list of records. const _colRows = (val) => Array.isArray(val) ? val.filter(Boolean) : (val && typeof val === "object" ? Object.values(val).filter(Boolean) : []); // The id a record is stored and synced under — a collection may derive it (checks). (ldg) const _recId = (def, rec) => (def && def.idOf && rec && typeof rec === "object") ? def.idOf(rec) : (rec ? rec[def.idField] : undefined); let __isOnline = false; // ── Visible connection state ──────────────────────────────────────────── // "online" → bootstrapped against the server; changes are shared. // "offline" → server unreachable; working from localStorage ONLY. // "auth-expired" → the stored session token is dead; user must sign in again. // "connecting" → a bootstrap attempt is in flight. // NOTHING here fails silently: every transition fires the connListeners set // AND a "k2b-conn" DOM event so the shell can show a banner. let __connState = "connecting"; const connListeners = new Set(); function fireConn(state) { connListeners.forEach(fn => { try { fn(state); } catch (e) {} }); try { if (typeof window !== "undefined" && typeof CustomEvent !== "undefined") { window.dispatchEvent(new CustomEvent("k2b-conn", { detail: { state } })); } } catch (e) {} } // ── Save-failure toast (once per transition) ──────────────────────────── // No success toast: syncing quietly is the normal state and the toolbar dot // already shows it. The failure toast fires exactly ONCE per drop into a // failed state (offline / auth-expired) — silent retries never re-toast; a // recovery to "online" re-arms the guard for the next real drop. let __failToasted = false; // last-state guard: armed ↔ we have not toasted since last failure function toastSaveFail() { if (typeof window !== "undefined") window.k2bToast && window.k2bToast("⚠ NOT saved to server — working locally", "error", 4000); } // ── Per-record permanent sync failures (poison records) ───────────────── // Records the server permanently rejected (400/403/404/422). They are pulled // out of the dirty queue so ONE bad record can't wedge the whole flush, and // surfaced here for Settings → Data & Backup. Session-scoped, capped. const SYNC_ISSUES_MAX = 100; let __syncIssues = []; // { collection, id, status, error, at } function recordSyncIssue(collection, id, status, error) { const at = Date.now(); const ix = __syncIssues.findIndex(x => x.collection === collection && String(x.id) === String(id)); const entry = { collection, id, status, error: error || ("HTTP " + status), at }; if (ix >= 0) __syncIssues[ix] = entry; // dedupe: keep latest per record else __syncIssues.push(entry); if (__syncIssues.length > SYNC_ISSUES_MAX) __syncIssues = __syncIssues.slice(-SYNC_ISSUES_MAX); } // Permanent per-record rejections — retrying the identical payload can't help. const isPermanentStatus = (st) => st === 400 || st === 403 || st === 404 || st === 422; // ── (ledger lane / ldg) The financial collections: the server validates, version-checks and audits every write of these. A // refusal of one is FINAL for the payload it answered — the ledger's own answers (ledger:true) and any refusal that names a // code but hands back no server copy to merge (the check-number guard, the reload message): never merged, never kept locally // as a "sync issue" to retry, the person sees the reason (dmsLedgerRefused). Other collections keep the RC14 poison-record path. const DMS_FINANCIAL = { journalEntries: 1, glAccounts: 1, checks: 1, checkConfig: 1, bankRec: 1, acctPeriod: 1, payables: 1, vendors: 1, employees: 1, commissions: 1, deskScenarios: 1, lenderSubmissions: 1, factoryReceivables: 1, floorplanPayoffsDue: 1, warrantyReceivables: 1, arCustomers: 1, arLetters: 1, arDefaults: 1, arAccountTypes: 1, arTaxCodes: 1, arDunningMessages: 1, arLetterTemplates: 1 }; const isFinalRefusal = (col, body) => !!(body && typeof body === "object" && body.ok === false && (body.ledger === true || (DMS_FINANCIAL[col] && typeof body.code === "string" && body.record === undefined && body.rev === undefined))); let __financialRefusals = []; // { collection, id, status, code, error, at } — what the server would not take and why (shown, never stored) function recordFinancialRefusal(collection, id, status, body) { const entry = { collection, id, status, code: body && body.code, error: (body && body.error) || ("HTTP " + status), at: Date.now() }; __financialRefusals = __financialRefusals.filter(x => !(x.collection === collection && String(x.id) === String(id))).concat([entry]).slice(-SYNC_ISSUES_MAX); } // (ldg, spec 16.1) The server answered 426 / "reload_required": this page is older than the server's minimum. Reload once; // if the reload still lands on an old page (a cache), say so instead of looping. let __staleHandled = false, __staleAt = 0; function dmsStaleReload(body) { if (typeof window === "undefined") return; const msg = (body && body.error) || "This station is running an older DMS. Reload the DMS (Ctrl+F5)."; let already = false; try { already = sessionStorage.getItem("k2b-proto-reloaded") === "1"; } catch (e) {} if (!already) { try { sessionStorage.setItem("k2b-proto-reloaded", "1"); } catch (e) {} if (!__staleHandled) { __staleHandled = true; __staleAt = Date.now(); try { window.k2bToast && window.k2bToast("⟳ " + msg + " Reloading now…", "warn", 4000); } catch (e) {} setTimeout(() => { try { location.reload(); } catch (e) {} }, 900); } return; } if (!__staleHandled || Date.now() - __staleAt > 30000) { __staleHandled = true; __staleAt = Date.now(); try { window.k2bToast && window.k2bToast("⚠ " + msg, "error", 12000); } catch (e) {} } try { window.dispatchEvent(new CustomEvent("k2b-stale", { detail: { error: msg, body } })); } catch (e) {} } const dmsProtocolOk = () => { try { sessionStorage.removeItem("k2b-proto-reloaded"); } catch (e) {} __staleHandled = false; }; function setConnState(next, opts) { if (__connState === next) return; __connState = next; __isOnline = (next === "online"); if (next === "online") { __failToasted = false; // re-arm the one-shot failure alarm } else if ((next === "offline" || next === "auth-expired") && !(opts && opts.quiet)) { // One toast per transition INTO a failed state — not per attempt/flush. if (!__failToasted) { __failToasted = true; toastSaveFail(); } } fireConn(next); } // Decode a JWT payload (base64url, NO signature verification — display only). // Returns { sub, role, name, exp, expired } or null. function decodeTokenInfo(tok) { const t = (tok === undefined) ? AUTH_TOKEN : tok; if (!t || typeof t !== "string") return null; try { const part = t.split(".")[1]; if (!part) return null; let b64 = part.replace(/-/g, "+").replace(/_/g, "/"); while (b64.length % 4) b64 += "="; const payload = JSON.parse(decodeURIComponent(escape(atob(b64)))); const exp = Number(payload.exp) || 0; return { sub: payload.sub, role: payload.role, name: payload.name, exp, store: payload.store != null ? String(payload.store) : null, expired: !!exp && (exp * 1000 <= Date.now()), }; } catch (e) { return null; } } let __sessionId = null; try { __sessionId = sessionStorage.getItem("jbrews-session"); if (!__sessionId) { __sessionId = "S" + Math.random().toString(36).slice(2, 10).toUpperCase(); sessionStorage.setItem("jbrews-session", __sessionId); } } catch (e) { __sessionId = "S" + Math.random().toString(36).slice(2, 10).toUpperCase(); } let __k2bInflight = 0; function dmsStoreSignedIn(r) { try { if (!window.K2BStore) return; window.K2BStore._answer(r); const s = window.K2BStore.id(); if (s) window.K2BStore._setLast(s); } catch (e) {} } // RC35 (brd): reload into the store the session is in (once per tab and reason - never a loop) function dmsStoreReload(why) { const k = "k2b-store-reload-" + (why || "store"); try { if (sessionStorage.getItem(k)) return false; sessionStorage.setItem(k, String(Date.now())); } catch (e) {} try { location.reload(); } catch (e) {} return true; } // RC35 (brd, SPEC 4.1 step 2): a sign-in kept from before RC35 (its token names no store) is moved into its store once, then the page reloads async function dmsStoreUpgrade() { const ti = decodeTokenInfo(); if (!ti || ti.store || !window.K2BStore) return "ok"; try { if (sessionStorage.getItem("k2b-store-upgrade")) return "ok"; sessionStorage.setItem("k2b-store-upgrade", "1"); } catch (e) {} try { const a = await apiGet("/stores"); window.K2BStore._answer(a); const to = a && (a.active || a.home); if (!to) return "ok"; const sw = await apiPost("/session/store", { store: to }); if (sw && sw.token) { setAuthToken(sw.token); window.K2BStore._setLast(to); try { location.reload(); } catch (e) {} return "reload"; } } catch (e) { /* an RC34 server (no stores yet): keep working as RC34 */ } return "ok"; } async function apiFetch(path, opts = {}) { const headers = Object.assign({ "Content-Type": "application/json" }, opts.headers || {}); const sentAuth = !!AUTH_TOKEN; // did this request carry our session token? if (AUTH_TOKEN) headers["Authorization"] = "Bearer " + AUTH_TOKEN; if (__sessionId) headers["X-Session-Id"] = __sessionId; headers["X-K2B-Protocol"] = String(DMS_PROTOCOL); // (ldg) the page's protocol number (spec 16.1) const pageStore = window.K2BStore && window.K2BStore.pageId(); if (pageStore && sentAuth) headers["X-K2B-Store"] = pageStore; // RC35 (brd): the store this page shows - the server refuses a request whose token is in another store (409) const write = String(opts.method || "GET").toUpperCase() !== "GET"; if (write) __k2bInflight++; // RC35 (brd): a store switch waits for writes in flight (SPEC 4.4) let res; try { res = await fetch(API_BASE + path, { method: opts.method || "GET", headers, body: opts.body != null ? JSON.stringify(opts.body) : undefined, credentials: "omit", }); } finally { if (write) __k2bInflight--; } if (!res.ok) { let msg = res.status + " " + res.statusText; let body = null; try { body = await res.json(); if (body && body.error) msg = body.error; } catch (e) {} if (body && (body.code === "store_switched" || body.code === "store_denied" || body.code === "store_off") && typeof window.k2bStoreRefusal === "function") { try { window.k2bStoreRefusal(body, res.status, path); } catch (e) {} } // ── Expired-token hygiene ── // A 401 on a request that CARRIED our token means the session is dead // (expired or revoked). Clear it everywhere and go LOUDLY to // "auth-expired" — no silent slide into localStorage-only mode. // (401 on /login or /mfa/* is just bad credentials — not a dead session.) if (res.status === 401 && sentAuth && !/^\/login\b/.test(path) && !/^\/mfa\b/.test(path)) { setAuthToken(null); setConnState("auth-expired"); } // (ldg, spec 16.1) an older page: the server refused a financial write / command — reload once, then say so if (res.status === 426 || (body && (body.code === "stale_client" || body.code === "reload_required"))) dmsStaleReload(body); const err = new Error(msg); err.status = res.status; err.body = body; // RC22 (own): the server offers the OWNER (only the owner) an "Owner override" for this refusal — the Owner panel's dialog asks for // the reason (and a fresh sign-in code), gets a single-use token bound to THIS request and sends it again; the answer comes back // here as if the first try had worked. Cancelled → the refusal stands, exactly as before. if (body && body.ownerOverride && String(opts.method || "GET").toUpperCase() === "POST" && !opts.noOwnerOverride && typeof window !== "undefined" && typeof window.k2bOwnerOverride === "function") { const ov = await window.k2bOwnerOverride({ path, method: "POST", reqBody: opts.body, headers: opts.headers || {}, refusal: body, status: res.status, err }); if (ov && ov.ok) return ov.result; if (ov && ov.err) throw ov.err; } throw err; } if (res.status === 204) return null; return res.json(); } const apiGet = (p) => apiFetch(p); const apiPost = (p, b) => apiFetch(p, { method: "POST", body: b }); const apiPostH = (p, b, h) => apiFetch(p, { method: "POST", body: b, headers: h }); // (ldg) a command with its Idempotency-Key const apiDelete = (p) => apiFetch(p, { method: "DELETE" }); // (sales features H9) Binary GET (trade photos) -> Blob. Same token / session headers and the same // 401 handling as apiFetch. Show the result only inside an (object URL); never open it in a window. async function dmsApiBlob(path) { const headers = {}; const sentAuth = !!AUTH_TOKEN; if (AUTH_TOKEN) headers["Authorization"] = "Bearer " + AUTH_TOKEN; if (__sessionId) headers["X-Session-Id"] = __sessionId; headers["X-K2B-Protocol"] = String(DMS_PROTOCOL); const pageStore = window.K2BStore && window.K2BStore.pageId(); if (pageStore && sentAuth) headers["X-K2B-Store"] = pageStore; // RC35 (brd) const res = await fetch(API_BASE + path, { method: "GET", headers, credentials: "omit" }); if (!res.ok) { let msg = res.status + " " + res.statusText; let body = null; try { body = await res.json(); if (body && body.error) msg = body.error; } catch (e) {} if (res.status === 401 && sentAuth) { setAuthToken(null); setConnState("auth-expired"); } const err = new Error(msg); err.status = res.status; err.body = body; throw err; } return res.blob(); } // ── GL account map (the posting accounts - the same numbers in every store) ──────────────────────────── const ACCT = { cashBank: "202", // Bank of New Madrid (operating) cashOnHand: "201", cit: "205", // Contracts in Transit arCustomer: "220", // A/R service & parts invNewCar: "231", invNewTruck: "237", invUsedCar: "240", invUsedTruck: "241", invParts: "242", warrantyRec: "263", insuranceRec: "262", // collision insurance receivable factoryRec: "261", // factory receivable floorNewGM: "310", floorUsed: "314", payoffPayable: "303", // trade payoffs payable salesTaxPay: "324A", // vehicle sales tax titlePay: "905D", // license/title/taxes (clearing) docFee: "905A", // doc fees comPayable: "321", // salaries/commissions payable custDeposits: "331", // sales / cost saleNewTruck: "429A", // Silverado (representative new-truck sale acct) costNewTruck: "629A", saleNewCar: "400A", costNewCar: "600A", saleUsedCar: "446B", costUsedCar: "646B", saleUsedTruck: "450B", costUsedTruck: "650B", fiNew: "806", // finance income new fiUsed: "808", // finance income used vscPayable: "302", // service-contract (warranty) payable gapPayable: "302G", // service / parts custLabor: "460A", costLabor: "660A", warrLabor: "462", internalLabor: "463", partsCarRO: "467", costPartsRO: "667", partsCounter: "482", costPartsCounter:"682", commExpNew: "0111", // sales comp - new commExpUsed: "0112", // RO split-pay posting (ServiceCore / closeRO) contractLabor: "460B", // sale - labor - service contract contractRec: "264", // service-contract claims receivable svcCourtesy: "0675", // courtesy → POLICY WORK - SERV (the built-in chart); 609 is office supplies cardFeeIncome: "905E", // card convenience fee charged to the customer cardDeposit: "202", // card receipts — processor settles to operating bank laborApplied: "999", // labor applied / clearing (offset to cost of labor) subletSale: "466", // sale - sublet repairs (outside work on ROs) subletCost: "666", // cost - sublet repairs subletInv: "246", // sublet repairs inventory (vendor bill parked here until the RO closes) }; // Editor metadata for the GL-flow mapping screen (Settings → GL Flow). // group → which posting flow the account drives; label → plain-English role. const ACCT_META = { cashBank: { group: "Cash & Banking", label: "Operating bank (deal proceeds, payoffs, cash out)" }, cashOnHand: { group: "Cash & Banking", label: "Cash on hand / parts & service till" }, cit: { group: "Receivables", label: "Contracts in transit (deal proceeds before funding)" }, arCustomer: { group: "Receivables", label: "Customer accounts receivable" }, warrantyRec: { group: "Receivables", label: "Warranty claim receivable" }, insuranceRec: { group: "Receivables", label: "Collision insurance receivable" }, factoryRec: { group: "Receivables", label: "Factory receivable" }, invNewCar: { group: "Inventory", label: "New car inventory" }, invNewTruck: { group: "Inventory", label: "New truck inventory" }, invUsedCar: { group: "Inventory", label: "Used car inventory" }, invUsedTruck: { group: "Inventory", label: "Used truck inventory" }, invParts: { group: "Inventory", label: "Parts inventory" }, floorNewGM: { group: "Floorplan & Payables", label: "New-vehicle floorplan payable (GM)" }, floorUsed: { group: "Floorplan & Payables", label: "Used-vehicle floorplan payable" }, payoffPayable: { group: "Floorplan & Payables", label: "Trade lien payoffs payable" }, salesTaxPay: { group: "Tax & Title", label: "Vehicle sales tax payable" }, titlePay: { group: "Tax & Title", label: "License / title / fees clearing" }, docFee: { group: "Tax & Title", label: "Doc-fee income" }, comPayable: { group: "Floorplan & Payables", label: "Salaries / commissions payable" }, custDeposits: { group: "Floorplan & Payables", label: "Customer deposits" }, saleNewCar: { group: "Vehicle Sales", label: "New car — sale (revenue)" }, costNewCar: { group: "Vehicle Sales", label: "New car — cost of sale" }, saleNewTruck: { group: "Vehicle Sales", label: "New truck — sale (revenue)" }, costNewTruck: { group: "Vehicle Sales", label: "New truck — cost of sale" }, saleUsedCar: { group: "Vehicle Sales", label: "Used car — sale (revenue)" }, costUsedCar: { group: "Vehicle Sales", label: "Used car — cost of sale" }, saleUsedTruck: { group: "Vehicle Sales", label: "Used truck — sale (revenue)" }, costUsedTruck: { group: "Vehicle Sales", label: "Used truck — cost of sale" }, fiNew: { group: "F&I", label: "Finance income — new" }, fiUsed: { group: "F&I", label: "Finance income — used" }, vscPayable: { group: "F&I", label: "Service-contract payable" }, gapPayable: { group: "F&I", label: "GAP payable" }, custLabor: { group: "Service", label: "Customer labor sale" }, costLabor: { group: "Service", label: "Cost of labor" }, warrLabor: { group: "Service", label: "Warranty labor sale" }, internalLabor: { group: "Service", label: "Internal labor sale" }, partsCarRO: { group: "Parts", label: "Parts sold on repair orders" }, costPartsRO: { group: "Parts", label: "Cost of parts on ROs" }, partsCounter: { group: "Parts", label: "Counter parts sale" }, costPartsCounter:{ group: "Parts", label: "Cost of counter parts" }, commExpNew: { group: "Expenses", label: "Sales compensation — new" }, commExpUsed: { group: "Expenses", label: "Sales compensation — used" }, contractLabor: { group: "Service", label: "Service-contract labor sale" }, contractRec: { group: "Receivables", label: "Service-contract claims receivable" }, svcCourtesy: { group: "Service", label: "Courtesy — service dept gross" }, cardFeeIncome: { group: "Service", label: "Card convenience fee income" }, cardDeposit: { group: "Cash & Banking", label: "Card receipts (processor deposit)" }, laborApplied: { group: "Service", label: "Labor applied / clearing" }, subletSale: { group: "Service", label: "Sublet (outside work) sale" }, subletCost: { group: "Service", label: "Sublet (outside work) cost" }, subletInv: { group: "Service", label: "Sublet repairs inventory" }, }; const ACCT_DEFAULTS = { ...ACCT }; const ACCT_OVERRIDE_KEY = "jbrews-acct-overrides-v1"; // Apply any saved overrides INTO the live ACCT object so every posting path // (postDeal, closeRO, parts, stock-in) uses the dealer's remapped accounts. try { const ov = JSON.parse(localStorage.getItem(ACCT_OVERRIDE_KEY) || "{}"); Object.keys(ov).forEach(k => { if (k in ACCT && ov[k]) ACCT[k] = String(ov[k]).trim(); }); } catch (e) {} // ════════════════════════════════════════════════════════════════════════ // GL POSTING MAP — every transaction in every department, fully mapped. // Each line references an ACCT key (so it tracks the dealer's editable // account mapping) or a literal account string. side: "DR" | "CR" | "DR/CR". // api.getPostingMap() resolves keys → live account numbers for display. // ════════════════════════════════════════════════════════════════════════ const POSTING_MAP = [ { dept: "Vehicle Sales & F&I", txns: [ { doc: "New vehicle retail sale", jrn: 10, prefix: "VS-", trigger: "Deals → Post to GL (new unit)", lines: [ { side: "DR", key: "cit", memo: "Contract in transit (amount financed)" }, { side: "DR", key: "cashOnHand", memo: "Cash down payment (cash deal: each payment recorded — never the amount owed)" }, { side: "CR", key: "saleNewCar", memo: "Vehicle sale — revenue" }, { side: "DR", key: "factoryRec", memo: "Customer rebate — factory receivable (cash deal)" }, { side: "DR/CR", key: "saleNewCar", memo: "Trade over-allowance (DR, reduces the sale) / under-allowance (CR) — cash deal" }, { side: "DR", key: "costNewCar", memo: "Cost of vehicle sold" }, { side: "CR", key: "invNewCar", memo: "Relieve new vehicle inventory" }, { side: "CR", key: "salesTaxPay", memo: "Sales tax payable" }, { side: "CR", key: "docFee", memo: "Doc fee income" }, { side: "CR", key: "titlePay", memo: "License / title payable (clearing)" }, { side: "CR", key: "payoffPayable",memo: "Trade lien payoff payable (if trade)" }, { side: "CR", key: "vscPayable", memo: "Service-contract payable (if sold)" }, { side: "CR", key: "gapPayable", memo: "GAP payable (if sold)" }, ] }, { doc: "Used vehicle retail sale", jrn: 11, prefix: "CS-", trigger: "Deals → Post to GL (used unit)", lines: [ { side: "DR", key: "cit", memo: "Contract in transit (amount financed)" }, { side: "DR", key: "cashOnHand", memo: "Cash down payment (cash deal: each payment recorded — never the amount owed)" }, { side: "CR", key: "saleUsedCar", memo: "Vehicle sale — revenue" }, { side: "DR", key: "factoryRec", memo: "Customer rebate — factory receivable (cash deal)" }, { side: "DR/CR", key: "saleUsedCar", memo: "Trade over-allowance (DR, reduces the sale) / under-allowance (CR) — cash deal" }, { side: "DR", key: "costUsedCar", memo: "Cost of vehicle sold" }, { side: "CR", key: "invUsedCar", memo: "Relieve used vehicle inventory" }, { side: "DR", key: "invUsedCar", memo: "Trade-in into inventory at ACV" }, { side: "CR", key: "salesTaxPay", memo: "Sales tax payable" }, { side: "CR", key: "payoffPayable",memo: "Trade lien payoff payable" }, ] }, { doc: "F&I income recognition", jrn: 80, prefix: "FI-", trigger: "Posts with the deal", lines: [ { side: "DR", key: "arCustomer", memo: "F&I receivable" }, { side: "CR", key: "fiNew", memo: "Finance reserve income — new" }, { side: "CR", key: "fiUsed", memo: "Finance reserve income — used" }, ] }, { doc: "Sales commission accrual", jrn: 80, prefix: "CM-", trigger: "Posts with the deal", lines: [ { side: "DR", key: "commExpNew", memo: "Sales compensation expense — new" }, { side: "DR", key: "commExpUsed", memo: "Sales compensation expense — used" }, { side: "CR", key: "comPayable", memo: "Commissions payable (→ payroll)" }, ] }, ] }, { dept: "Inventory & Floorplan", txns: [ { doc: "New vehicle stock-in (cash)", jrn: 70, prefix: "NP-", trigger: "Inventory → Add Unit / Stock In", lines: [ { side: "DR", key: "invNewCar", memo: "Vehicle into inventory at cost" }, { side: "CR", key: "cashBank", memo: "Operating cash (un-floored portion)" }, ] }, { doc: "New vehicle stock-in (floored)", jrn: 70, prefix: "NP-", trigger: "Stock In with floorplan advance", lines: [ { side: "DR", key: "invNewCar", memo: "Vehicle into inventory at cost" }, { side: "CR", key: "floorNewGM", memo: "New-vehicle floorplan payable (GM)" }, { side: "CR", key: "cashBank", memo: "Cash for any un-floored portion" }, ] }, { doc: "Used vehicle stock-in", jrn: 71, prefix: "UP-", trigger: "Stock In / trade to inventory", lines: [ { side: "DR", key: "invUsedCar", memo: "Vehicle into inventory at cost" }, { side: "CR", key: "floorUsed", memo: "Used floorplan payable (if floored)" }, { side: "CR", key: "cashBank", memo: "Cash for un-floored portion" }, ] }, { doc: "Floorplan payoff on sale", jrn: 72, prefix: "FP-", trigger: "Unit sold → pay floor", lines: [ { side: "DR", key: "floorNewGM", memo: "Clear floorplan payable" }, { side: "CR", key: "cashBank", memo: "Pay floorplan lender" }, ] }, ] }, { dept: "Service", txns: [ { doc: "Customer-pay repair order", jrn: 30, prefix: "ST-", trigger: "Service → Close RO (customer pay)", lines: [ { side: "DR", key: "arCustomer", memo: "Receivable / cash — RO total" }, { side: "CR", key: "custLabor", memo: "Customer labor sale" }, { side: "DR", key: "costLabor", memo: "Cost of labor" }, { side: "CR", key: "partsCarRO", memo: "Parts sold on RO" }, { side: "DR", key: "costPartsRO", memo: "Cost of parts on RO" }, { side: "CR", key: "invParts", memo: "Relieve parts inventory" }, { side: "CR", key: "salesTaxPay", memo: "Sales tax payable" }, ] }, { doc: "Warranty repair order", jrn: 30, prefix: "ST-", trigger: "Close RO (warranty)", lines: [ { side: "DR", key: "warrantyRec", memo: "Warranty receivable (claim filed)" }, { side: "CR", key: "warrLabor", memo: "Warranty labor sale" }, { side: "DR", key: "costLabor", memo: "Cost of labor" }, { side: "CR", key: "partsCarRO", memo: "Parts on RO" }, { side: "CR", key: "invParts", memo: "Relieve parts inventory" }, ] }, { doc: "Internal / recon repair order", jrn: 30, prefix: "ST-", trigger: "Close RO (internal)", lines: [ { side: "DR", key: "invUsedCar", memo: "Charge to vehicle (adds recon to unit cost)" }, { side: "CR", key: "internalLabor",memo: "Internal labor sale" }, { side: "DR", key: "costLabor", memo: "Cost of labor" }, { side: "CR", key: "partsCarRO", memo: "Parts on RO" }, ] }, ] }, { dept: "Parts", txns: [ { doc: "Counter parts sale", jrn: 35, prefix: "PT-", trigger: "Parts → Sell / Parts Ticket", lines: [ { side: "DR", key: "cashOnHand", memo: "Cash / till — sale + tax" }, { side: "CR", key: "partsCounter", memo: "Counter parts sale (revenue)" }, { side: "DR", key: "costPartsCounter",memo: "Cost of counter parts" }, { side: "CR", key: "invParts", memo: "Relieve parts inventory" }, { side: "CR", key: "salesTaxPay", memo: "Sales tax payable (retail)" }, ] }, { doc: "Receive purchase order", jrn: 75, prefix: "AP-", trigger: "Parts → Receive PO", lines: [ { side: "DR", key: "invParts", memo: "Parts into inventory at cost" }, { side: "CR", lit: "300", memo: "Accounts payable — vendor" }, ] }, { doc: "Physical count adjustment", jrn: 80, prefix: "PI-", trigger: "Parts → Physical Inventory", lines: [ { side: "DR/CR", key: "invParts", memo: "Inventory up/down by variance × cost" }, { side: "CR/DR", key: "costPartsCounter",memo: "Offset to cost of sales" }, ] }, ] }, { dept: "Core Bank", txns: [ { doc: "Core received from customer", jrn: 75, prefix: "COC-", trigger: "Parts → Core Bank → Receive Core", lines: [ { side: "DR", lit: "246", memo: "Core inventory (on shelf)" }, { side: "CR", key: "cashBank", memo: "Deposit refunded to customer" }, ] }, { doc: "GM core credit received", jrn: 75, prefix: "COG-", trigger: "Core Bank → Post Credit", lines: [ { side: "DR", key: "cashBank", memo: "GM core credit received" }, { side: "CR", lit: "246", memo: "Clear core inventory" }, ] }, ] }, { dept: "Accounting", txns: [ { doc: "Customer A/R payment", jrn: 40, prefix: "CR-", trigger: "Accounting Office → A/R → Take Payment", lines: [ { side: "DR", key: "cashBank", memo: "Cash received" }, { side: "CR", key: "arCustomer", memo: "Clear customer receivable" }, ] }, { doc: "A/P invoice payment", jrn: 45, prefix: "AP-", trigger: "Accounting Office → A/P → Pay Invoice", lines: [ { side: "DR", lit: "300", memo: "Clear accounts payable" }, { side: "CR", key: "cashBank", memo: "Cash disbursed" }, ] }, { doc: "Payroll run", jrn: 50, prefix: "PR-", trigger: "Payroll → Process", lines: [ { side: "DR", key: "comPayable", memo: "Clear commissions / wages payable" }, { side: "CR", key: "cashBank", memo: "Net pay (EFT)" }, { side: "CR", lit: "325", memo: "Payroll taxes withheld (payable)" }, ] }, { doc: "Federal 941 tax deposit", jrn: 50, prefix: "PF-", trigger: "Payroll Tax → Pay Taxes (EFTPS)", lines: [ { side: "DR", lit: "325", memo: "Clear federal payroll-tax liability" }, { side: "CR", key: "cashBank", memo: "EFTPS deposit — FIT + SS + Medicare" }, ] }, { doc: "State withholding deposit", jrn: 50, prefix: "PS-", trigger: "Payroll Tax → Pay Taxes (MO-DOR)", lines: [ { side: "DR", lit: "326", memo: "Clear state withholding liability" }, { side: "CR", key: "cashBank", memo: "MO Dept. of Revenue deposit" }, ] }, { doc: "Employee deduction remittance", jrn: 75, prefix: "CK-", trigger: "Payroll → Pay Deductions (child support / garnishments)", lines: [ { side: "DR", lit: "326", memo: "Clear withheld deductions payable" }, { side: "CR", key: "cashBank", memo: "Vendor check to deduction payee" }, ] }, { doc: "Manual journal entry", jrn: "—", prefix: "JE-", trigger: "Accounting → Doc Entry", lines: [ { side: "DR/CR", lit: "any", memo: "Operator-entered balanced entry" }, ] }, ] }, ]; // ── default seed (a realistic sample day - "Load Sample Data", the primary store only) ────────────────── const _today = () => new Date(); const _iso = (d) => d.toISOString(); const _daysAgo = (n) => { const d = new Date(); d.setDate(d.getDate() - n); return d; }; const daysSince = (iso) => iso ? Math.floor((Date.now() - new Date(iso)) / 86400000) : 0; const _fmtDate = (d) => new Date(d).toLocaleDateString("en-US", { month: "2-digit", day: "2-digit", year: "2-digit" }); const DMS_SEED = { meta: { seeded: true, version: 1 }, // ── Inventory ────────────────────────────────────────────────────── inventory: [ { stock: "T26-2122", vin: "1GCUYDED5RZ118842", yr: 2026, mk: "Chevrolet", mdl: "Silverado 1500 LT", type: "New Truck", cost: 51840.00, floorAmt: 51840.00, floorLender: "GM Financial", pack: 595, recon: 0, asking: 58995.00, age: 22, status: "In Stock", saleAcct: ACCT.saleNewTruck, costAcct: ACCT.costNewTruck, invAcct: ACCT.invNewTruck, floorAcct: ACCT.floorNewGM }, { stock: "T26-2140", vin: "1GCPYFED2RZ221190", yr: 2026, mk: "Chevrolet", mdl: "Silverado 2500 LTZ", type: "New Truck", cost: 68210.00, floorAmt: 68210.00, floorLender: "GM Financial", pack: 595, recon: 0, asking: 76480.00, age: 9, status: "In Stock", saleAcct: ACCT.saleNewTruck, costAcct: ACCT.costNewTruck, invAcct: ACCT.invNewTruck, floorAcct: ACCT.floorNewGM }, { stock: "C26-1188", vin: "1G1ZD5ST8RF142200", yr: 2026, mk: "Chevrolet", mdl: "Malibu LT", type: "New Car", cost: 26840.00, floorAmt: 26840.00, floorLender: "GM Financial", pack: 395, recon: 0, asking: 29995.00, age: 71, status: "In Stock", saleAcct: ACCT.saleNewCar, costAcct: ACCT.costNewCar, invAcct: ACCT.invNewCar, floorAcct: ACCT.floorNewGM }, { stock: "C26-1205", vin: "1G1FB1RS9R0118820", yr: 2026, mk: "Chevrolet", mdl: "Camaro LT1", type: "New Car", cost: 41200.00, floorAmt: 41200.00, floorLender: "GM Financial", pack: 395, recon: 0, asking: 46900.00, age: 118, status: "In Stock", saleAcct: ACCT.saleNewCar, costAcct: ACCT.costNewCar, invAcct: ACCT.invNewCar, floorAcct: ACCT.floorNewGM }, { stock: "U26-8841", vin: "3GNAXUEG8PL188421", yr: 2023, mk: "Chevrolet", mdl: "Equinox LT", type: "Used", subType: "Used Car", cost: 22100.00, floorAmt: 22100.00, floorLender: "Bank of New Madrid", pack: 0, recon: 1240.00, asking: 25995.00, age: 47, status: "In Stock", saleAcct: ACCT.saleUsedCar, costAcct: ACCT.costUsedCar, invAcct: ACCT.invUsedCar, floorAcct: ACCT.floorUsed }, { stock: "U26-8855", vin: "1FTFW1E84MFA22019", yr: 2021, mk: "Ford", mdl: "F-150 XLT", type: "Used", subType: "Used Truck", cost: 31900.00, floorAmt: 31900.00, floorLender: "Bank of New Madrid", pack: 0, recon: 2890.00, asking: 36995.00, age: 84, status: "In Stock", saleAcct: ACCT.saleUsedTruck, costAcct: ACCT.costUsedTruck, invAcct: ACCT.invUsedTruck, floorAcct: ACCT.floorUsed }, ], // ── Deals (various statuses to drive exceptions) ─────────────────── deals: [ { id: "DR-2026141", buyer: "TANNER, MICHAEL", stock: "U26-8830", vehicle: "2022 Chevrolet Tahoe LT", type: "Used", salesperson: "Jayme King", spId: "JAYME", salePrice: 48900.00, cost: 43100.00, fiGross: 1850.00, tax: 3534.53, docFee: 199.00, titleFee: 52.50, tradeAllow: 0, tradePayoff: 0, cashDown: 2000.00, rebate: 0, lender: "GM Financial", amtFinanced: 50685.03, status: "Funded", postedToGL: true, fundedDate: _iso(_daysAgo(6)), delivered: _iso(_daysAgo(8)), created: _iso(_daysAgo(10)), vscSale: 2495.00, vscCost: 1495.00, gapSale: 895.00, gapCost: 350.00 }, { id: "DR-2026148", buyer: "ELLISON, KAREN", stock: "C26-1188", vehicle: "2026 Chevrolet Malibu LT", type: "New", salesperson: "Jayme King", spId: "JAYME", salePrice: 29995.00, cost: 26840.00, fiGross: 1100.00, tax: 2167.14, docFee: 199.00, titleFee: 52.50, tradeAllow: 8000.00, tradeACV: 6800.00, tradePayoff: 4200.00, cashDown: 1500.00, rebate: 1000.00, lender: "Ally", amtFinanced: 23913.64, status: "Delivered Not Funded", postedToGL: true, delivered: _iso(_daysAgo(4)), created: _iso(_daysAgo(5)), vscSale: 2195.00, vscCost: 1295.00, gapSale: 0, gapCost: 0, fundingIssue: "Awaiting proof of income stipulation from customer." }, { id: "DR-2026150", buyer: "RODRIGUEZ, ANA", stock: "T26-2140", vehicle: "2026 Chevrolet Silverado 2500 LTZ", type: "New", salesperson: "Jayme King", spId: "JAYME", salePrice: 76480.00, cost: 68210.00, fiGross: 2300.00, tax: 5525.68, docFee: 199.00, titleFee: 52.50, tradeAllow: 0, tradePayoff: 0, cashDown: 10000.00, rebate: 2000.00, lender: "GM Financial", amtFinanced: 70257.18, status: "Sold Not Delivered", postedToGL: false, created: _iso(_daysAgo(1)), vscSale: 0, vscCost: 0, gapSale: 0, gapCost: 0, pending: "Awaiting customer return for delivery — Wednesday 4pm." }, { id: "DR-2026152", buyer: "WHITFIELD, DARNELL", stock: "", vehicle: "Working — needs vehicle", type: "New", salesperson: "Jayme King", spId: "JAYME", salePrice: 0, cost: 0, fiGross: 0, tax: 0, docFee: 0, titleFee: 0, tradeAllow: 0, tradePayoff: 0, cashDown: 0, rebate: 0, lender: "", amtFinanced: 0, status: "Working", postedToGL: false, created: _iso(_daysAgo(0)) }, ], // ── Repair Orders ────────────────────────────────────────────────── repairOrders: [ { id: "RO-44120", customer: "MOORE, SUSAN", vehicle: "2021 Chevrolet Equinox", vin: "2GNAXUEV5M6112842", advisor: "Mark Service", techId: "TECH04", tech: "Danny R.", payType: "Customer", status: "Closed", postedToGL: true, laborSale: 285.00, laborCost: 95.00, flagHours: 2.0, partsSale: 142.20, partsCost: 84.30, tax: 30.42, total: 457.62, opened: _iso(_daysAgo(3)), closed: _iso(_daysAgo(2)) }, { id: "RO-44128", customer: "BANKS, GREGORY", vehicle: "2024 Chevrolet Silverado 1500", vin: "1GCUYDED2RZ110021", advisor: "Mark Service", techId: "TECH02", tech: "Carlos M.", payType: "Warranty", status: "Closed", postedToGL: true, laborSale: 420.00, laborCost: 140.00, flagHours: 3.0, partsSale: 388.50, partsCost: 240.10, tax: 0, total: 808.50, warrantyClaim: "WC-559210", claimSubmitted: false, opened: _iso(_daysAgo(5)), closed: _iso(_daysAgo(4)) }, { id: "RO-44131", customer: "INTERNAL — U26-8855", vehicle: "2021 Ford F-150 XLT (recon)", vin: "1FTFW1E84MFA22019", advisor: "Mark Service", techId: "TECH04", tech: "Danny R.", payType: "Internal", status: "Open", postedToGL: false, laborSale: 360.00, laborCost: 120.00, flagHours: 3.0, partsSale: 520.00, partsCost: 312.00, tax: 0, total: 880.00, internalStock: "U26-8855", opened: _iso(_daysAgo(2)) }, { id: "RO-44135", customer: "PHILLIPS, RENEE", vehicle: "2023 Chevrolet Equinox LT", vin: "3GNAXUEG8PL188421", advisor: "Mark Service", techId: "TECH02", tech: "Carlos M.", payType: "Customer", status: "Open", postedToGL: false, laborSale: 510.00, laborCost: 170.00, flagHours: 3.6, partsSale: 240.80, partsCost: 142.30, tax: 53.30, total: 804.10, opened: _iso(_daysAgo(9)) }, // aged open RO → exception ], // ── Technicians (dispatch + productivity) ────────────────────────── technicians: [ { id: "TECH02", name: "Carlos M.", skill: "A — Master", rate: 32.00, availToday: 8.0, flaggedToday: 6.4, flaggedMTD: 142.5, clockMTD: 168.0 }, { id: "TECH04", name: "Danny R.", skill: "B — Journeyman", rate: 26.00, availToday: 8.0, flaggedToday: 5.0, flaggedMTD: 121.0, clockMTD: 160.0 }, { id: "TECH06", name: "Will P.", skill: "C — Express", rate: 20.00, availToday: 8.0, flaggedToday: 7.2, flaggedMTD: 158.0, clockMTD: 162.0 }, { id: "TECH08", name: "Renee K.", skill: "A — Master", rate: 33.50, availToday: 8.0, flaggedToday: 4.5, flaggedMTD: 138.0, clockMTD: 170.0 }, ], // ── Appointments (today's schedule) ──────────────────────────────── appointments: [ { id: "AP-3401", time: "08:00", customer: "GARCIA, LUIS", vehicle: "2022 Chevrolet Equinox", concern: "Oil change + tire rotation", advisor: "Mark Service", techId: "TECH06", estHours: 0.8, transport: "Waiter", status: "Checked In" }, { id: "AP-3402", time: "08:30", customer: "NGUYEN, TRAN", vehicle: "2020 Chevrolet Silverado", concern: "Brake noise front", advisor: "Mark Service", techId: "TECH02", estHours: 2.0, transport: "Drop-off", status: "Scheduled" }, { id: "AP-3403", time: "09:00", customer: "OKAFOR, BLESSING",vehicle: "2023 Chevrolet Malibu", concern: "Check engine light", advisor: "Mark Service", techId: "TECH04", estHours: 1.5, transport: "Loaner", status: "Scheduled" }, { id: "AP-3404", time: "10:30", customer: "FLEET — CITY PW", vehicle: "2021 Chevrolet Tahoe PPV", concern: "60k service", advisor: "Mark Service", techId: "", estHours: 3.5, transport: "Drop-off", status: "Scheduled" }, { id: "AP-3405", time: "13:00", customer: "WALSH, KERRY", vehicle: "2024 Chevrolet Trax", concern: "Recall — software", advisor: "Mark Service", techId: "", estHours: 0.6, transport: "Waiter", status: "Scheduled" }, ], // ── Parts (master file for inventory control) ────────────────────── parts: [ { part: "12345678", desc: "OIL FILTER PF63", mfr: "ACDelco", source: "GM", bin: "A-12-03", onHand: 3, committed: 2, onOrder: 0, cost: 4.85, list: 9.99, trade: 7.49, lastSale: _iso(_daysAgo(1)), lastReceipt: _iso(_daysAgo(12)), age: 30, returnable: true, core: 0, stockStatus: "Stocked" }, { part: "19260218", desc: "BRAKE PAD SET FRONT", mfr: "ACDelco", source: "GM", bin: "B-04-11", onHand: 6, committed: 0, onOrder: 0, cost: 38.40, list: 79.99, trade: 62.40, lastSale: _iso(_daysAgo(2)), lastReceipt: _iso(_daysAgo(20)), age: 45, returnable: true, core: 0, stockStatus: "Stocked" }, { part: "84176475", desc: "CABIN AIR FILTER", mfr: "ACDelco", source: "GM", bin: "A-08-02", onHand: -1, committed: 0, onOrder: 4, cost: 11.20, list: 24.99, trade: 18.74, lastSale: _iso(_daysAgo(0)), lastReceipt: _iso(_daysAgo(2)), age: 60, returnable: true, core: 0, stockStatus: "Stocked" }, // negative → exception { part: "GM-22995", desc: "WIPER BLADE 22IN", mfr: "ACDelco", source: "GM", bin: "", onHand: 12, committed: 0, onOrder: 0, cost: 6.10, list: 14.99, trade: 11.24, lastSale: _iso(_daysAgo(400)), lastReceipt: _iso(_daysAgo(420)), age: 410, returnable: false, core: 0, stockStatus: "Phase-Out" }, // no bin + obsolete → exceptions { part: "84320012", desc: "TRIM PANEL LH", mfr: "GM", source: "GM", bin: "SO-SHELF", onHand: 1, committed: 1, onOrder: 0, cost: 142.00, list: 268.00, trade: 214.40, lastSale: null, lastReceipt: _iso(_daysAgo(6)), age: 95, returnable: false, core: 0, stockStatus: "Special", specialOrder: { ro: "RO-44135", customer: "PHILLIPS, RENEE", advisor: "Mark Service", ordered: _iso(_daysAgo(8)), eta: _iso(_daysAgo(2)), received: true, installed: false, contacted: false, deposit: 0 } }, { part: "19420559", desc: "BATTERY 48-AGM", mfr: "ACDelco", source: "GM", bin: "BAT-WALL", onHand: 5, committed: 1, onOrder: 0, cost: 118.00, list: 219.99, trade: 175.99, lastSale: _iso(_daysAgo(3)), lastReceipt: _iso(_daysAgo(15)), age: 22, returnable: true, core: 22.00, stockStatus: "Stocked" }, { part: "12690772", desc: "ALTERNATOR 220A", mfr: "ACDelco", source: "GM", bin: "C-02-08", onHand: 2, committed: 0, onOrder: 0, cost: 268.00, list: 489.99, trade: 391.99, lastSale: _iso(_daysAgo(40)), lastReceipt: _iso(_daysAgo(70)), age: 70, returnable: true, core: 75.00, stockStatus: "Stocked" }, { part: "84156868", desc: "FRONT ROTOR", mfr: "ACDelco", source: "GM", bin: "B-05-02", onHand: 8, committed: 0, onOrder: 0, cost: 44.20, list: 92.99, trade: 74.39, lastSale: _iso(_daysAgo(5)), lastReceipt: _iso(_daysAgo(25)), age: 38, returnable: true, core: 0, stockStatus: "Stocked" }, ], // Purchase orders (open replenishment / special / emergency) partsOrders: [ { po: "PO-77120", type: "Stock Order", vendor: "GM Parts", created: _iso(_daysAgo(3)), status: "Open", lines: [ { part: "84176475", desc: "CABIN AIR FILTER", qty: 4, cost: 11.20 }, { part: "12345678", desc: "OIL FILTER PF63", qty: 12, cost: 4.85 } ] }, ], // Core tracking (charged to customer, due back to manufacturer) cores: [ { part: "12690772", desc: "ALTERNATOR 220A", ro: "RO-44090", amount: 75.00, status: "Due from Customer", age: 14 }, ], // ── Receivables ──────────────────────────────────────────────────── warrantyReceivables: [ { claim: "WC-559210", ro: "RO-44128", amount: 808.50, submitted: false, status: "Not Submitted", closed: _iso(_daysAgo(4)) }, { claim: "WC-558840", ro: "RO-44102", amount: 612.40, submitted: true, status: "Submitted", paid: false, submittedDate: _iso(_daysAgo(35)) }, // aged unpaid ], factoryReceivables: [ { vin: "1GCUYDED5RZ118842", deal: "DR-2026120", program: "GM Conquest Cash", claimed: 1500.00, received: 0, age: 41 }, // aged unpaid { vin: "1G1ZD5ST8RF142200", deal: "DR-2026148", program: "Customer Cash", claimed: 1000.00, received: 0, age: 4 }, ], // ── Accounts Payable (vendor bills) ──────────────────────────────── payables: [ { vendor: "GM Parts", inv: "GMP-44821", date: _iso(_daysAgo(8)), due: _iso(_daysAgo(-7)), amount: 14820.50, acct: "242", status: "Open" }, { vendor: "ACDelco Distribution",inv: "AC-99120", date: _iso(_daysAgo(15)), due: _iso(_daysAgo(0)), amount: 6240.10, acct: "242", status: "Open" }, { vendor: "Sikeston Power & Light",inv: "UTIL-0526",date: _iso(_daysAgo(22)),due: _iso(_daysAgo(-3)), amount: 3180.40, acct: "0875", status: "Open" }, { vendor: "Riverbend Advertising",inv: "RA-2026-5", date: _iso(_daysAgo(34)), due: _iso(_daysAgo(4)), amount: 8900.00, acct: "0651", status: "Open" }, // aged → exception { vendor: "Courtois Glass (sublet)",inv: "CG-1182", date: _iso(_daysAgo(48)), due: _iso(_daysAgo(18)), amount: 1240.00, acct: "246", status: "Open" }, // aged { vendor: "GM Financial (floor int.)",inv: "FI-0526",date: _iso(_daysAgo(3)), due: _iso(_daysAgo(-12)),amount: 9420.18, acct: "0761", status: "Open" }, ], // ── Bank reconciliation (202 Bank of New Madrid) ─────────────────── bankRec: { account: "202 — Bank of New Madrid (Operating)", statementDate: _iso(_daysAgo(0)), bookBalance: 488402.10, bankBalance: 502184.55, outstandingChecks: [ { num: "48201", payee: "GM Parts", amount: 14820.50, date: _iso(_daysAgo(2)) }, { num: "48202", payee: "Riverbend Advertising", amount: 8900.00, date: _iso(_daysAgo(1)) }, { num: "48205", payee: "Payroll — direct dep", amount: 38842.18, date: _iso(_daysAgo(1)) }, ], depositsInTransit: [ { ref: "DDP-0526", desc: "Daily deposit 05/26", amount: 28402.20, date: _iso(_daysAgo(1)) }, { ref: "DDP-0527", desc: "Daily deposit 05/27", amount: 33758.93, date: _iso(_daysAgo(0)) }, ], bankFees: 85.00, interestEarned: 0, cleared: false, }, // ── Commissions (payroll accrual) ────────────────────────────────── commissions: [], // ── Audit trail (who did what, when) ─────────────────────────────── // Never seeded — the audit log only ever holds real actions (C-05). audit: [], // ── Floorplan units (notes payable detail) ───────────────────────── // derived from inventory[].floorAmt + sold units awaiting payoff floorplanPayoffsDue: [ { stock: "U26-8830", vin: "—", lender: "Bank of New Madrid", amount: 43100.00, soldDate: _iso(_daysAgo(8)), deal: "DR-2026141", paid: false }, // sold, floorplan not paid → exception ], // ── Lender credit-app submissions (the routing feed) ───────────────── lenderSubmissions: [], // ── Accounts Receivable — open-item sub-ledger for acct 220 ───────── // ARDFT defaults (matches the dealership's live config in the legacy K2B // "AR Defaults" screen). Editable from A/R › Setup. arDefaults: { arAccount: "220", arAccountName: "A/R SERVICE & PARTS", financeChargeAcct: "473", financeChargeAcctName: "FINANCE CHARGE INCOME", financeChargeJournal: "81", financeChargeJournalName: "FC JOURNAL", monthlyFinanceChargePct: 1.50, // % of past-due balance, assessed monthly minFinanceCharge: 1.00, graceDays: 30, // balances < graceDays old are not charged defaultTaxCode: "0100", defaultTaxName: "NM", depositJournal: "50", depositJournalName: "CASH RECEIPTS", chargeCreditCardFee: true, creditCardFeePct: 3.00, creditCardJournal: "2", creditCardJournalName: "CREDIT CARD RECEIPTS", creditCardDepositAcct: "201", creditCardFeeAcct: "0699", statementDay: 1, // statements cut on the 1st }, // Account-type table (terms templates) arAccountTypes: [ { code: "RTL", name: "Retail", terms: "Net 30", graceDays: 30, fcExempt: false }, { code: "WHL", name: "Wholesale", terms: "Net 30", graceDays: 30, fcExempt: false }, { code: "FLT", name: "Fleet", terms: "Net 30", graceDays: 30, fcExempt: false }, { code: "INT", name: "Internal", terms: "On Demand", graceDays: 0, fcExempt: true }, { code: "EMP", name: "Employee", terms: "Payroll Ded.", graceDays: 0, fcExempt: true }, ], // Tax-code table arTaxCodes: [ { code: "0100", name: "NM", rate: 7.225, desc: "New Madrid County" }, { code: "0200", name: "CITY", rate: 8.475, desc: "Sikeston City" }, { code: "0300", name: "STATE", rate: 4.225, desc: "Missouri State only" }, { code: "EXMT", name: "Exempt", rate: 0.000, desc: "Tax-exempt / resale" }, ], // Dunning messages printed on statements by age of oldest open item arDunningMessages: [ { bucket: "0-30", message: "Thank you for your business. Balance due upon receipt." }, { bucket: "31-60", message: "Your account is past due. Please remit payment promptly." }, { bucket: "61-90", message: "SECOND NOTICE — account seriously past due. Finance charges apply." }, { bucket: "90+", message: "FINAL NOTICE — account referred for collection. Call 573-555-0117." }, ], // Collection-letter templates arLetterTemplates: [ { code: "L1", name: "Friendly Reminder", bucket: "31-60" }, { code: "L2", name: "Past Due Notice", bucket: "61-90" }, { code: "L3", name: "Final Demand", bucket: "90+" }, ], // Sent-letter / dunning log arLetters: [], // Trade-in appraisals (from the Trade/Appraisal desk; can attach to a deal) appraisals: [], // Customer accounts (open-item). txns: charge (+) / credit (−). // kind: INV | PAY | FC | ADJ | DEP. Aging is computed FIFO at read time. arCustomers: [ { acct: "1001", name: "CITY OF SIKESTON — PUBLIC WORKS", type: "FLT", contact: "Fleet Office", phone: "573-555-0190", addr: "105 E Center St", city: "Sikeston, MO 63801", terms: "Net 30", creditLimit: 25000, taxCode: "EXMT", opened: _iso(_daysAgo(640)), txns: [ { id: "T-7740", date: _iso(_daysAgo(44)), kind: "INV", ref: "RO-44060", desc: "Tahoe PPV — 60k service", charge: 1284.90, credit: 0 }, { id: "T-7741", date: _iso(_daysAgo(44)), kind: "PAY", ref: "CR-9920", desc: "Check #88210", charge: 0, credit: 672.50 }, { id: "T-7802", date: _iso(_daysAgo(12)), kind: "INV", ref: "RO-44129", desc: "PD Interceptor — brakes", charge: 942.30, credit: 0 }, ] }, { acct: "1002", name: "RIVERBEND AUTO BODY", type: "WHL", contact: "Dale R.", phone: "573-555-0144", addr: "2210 Nash Rd", city: "Sikeston, MO 63801", terms: "Net 30", creditLimit: 15000, taxCode: "EXMT", opened: _iso(_daysAgo(420)), txns: [ { id: "T-7710", date: _iso(_daysAgo(8)), kind: "INV", ref: "WP-2210", desc: "Wholesale parts — sheet metal", charge: 842.10, credit: 0 }, { id: "T-7755", date: _iso(_daysAgo(3)), kind: "INV", ref: "WP-2255", desc: "Wholesale parts — trim/clips", charge: 1210.00, credit: 0 }, { id: "T-7760", date: _iso(_daysAgo(2)), kind: "PAY", ref: "CR-9941", desc: "ACH payment", charge: 0, credit: 500.00 }, ] }, { acct: "1003", name: "DELTA RIDGE RENTAL LLC", type: "FLT", contact: "A. Compton", phone: "573-555-0177", addr: "44 Industrial Dr", city: "Matthews, MO 63867", terms: "Net 30", creditLimit: 20000, taxCode: "EXMT", opened: _iso(_daysAgo(900)), txns: [ { id: "T-7510", date: _iso(_daysAgo(96)), kind: "INV", ref: "RO-43980", desc: "Fleet — 4x transmission svc", charge: 2450.00, credit: 0 }, { id: "T-7690", date: _iso(_daysAgo(34)), kind: "FC", ref: "FC-0426", desc: "Finance charge 04/26", charge: 36.75, credit: 0 }, ] }, { acct: "1004", name: "BOOTHEEL FARMS COOP", type: "FLT", contact: "J. Heisserer", phone: "573-555-0162", addr: "1180 Hwy 61", city: "Miner, MO 63801", terms: "Net 30", creditLimit: 30000, taxCode: "0100", opened: _iso(_daysAgo(540)), txns: [ { id: "T-7600", date: _iso(_daysAgo(62)), kind: "INV", ref: "WP-2188", desc: "Filters & fluids — bulk", charge: 3120.40, credit: 0 }, { id: "T-7820", date: _iso(_daysAgo(6)), kind: "INV", ref: "RO-44133", desc: "Silverado 3500 — DEF system", charge: 1180.25, credit: 0 }, ] }, { acct: "1005", name: "HARGROVE, BILL", type: "RTL", contact: "", phone: "573-555-0211", addr: "812 Greer Ave", city: "Sikeston, MO 63801", terms: "Net 30", creditLimit: 3000, taxCode: "0100", opened: _iso(_daysAgo(210)), txns: [ { id: "T-7790", date: _iso(_daysAgo(22)), kind: "INV", ref: "RO-44120", desc: "Equinox — diagnostic + repair", charge: 457.62, credit: 0 }, ] }, { acct: "1006", name: "DEALERSHIP — INTERNAL", type: "INT", contact: "Acct Office", phone: "573-555-0100", addr: "716 W Malone Ave", city: "Sikeston, MO 63801", terms: "On Demand", creditLimit: 0, taxCode: "EXMT", opened: _iso(_daysAgo(1200)), txns: [ { id: "T-7830", date: _iso(_daysAgo(2)), kind: "INV", ref: "RO-44131", desc: "Internal recon — U26-8855", charge: 880.00, credit: 0 }, ] }, ], }; // ── BLANK state (the default) ─────────────────────────────────────────── // Truly empty dealership: no demo deals, customers, inventory, ROs, parts, // appraisals or history. Keeps only the configuration scaffolding the program // needs to function (A/R posting defaults, account-type/tax/dunning/letter // tables). Use DMS.loadSampleData() to pull in the demo dataset, or // DMS.startBlank() to return to empty. const DMS_BLANK = { meta: { seeded: false, version: 1, blank: true }, inventory: [], deals: [], repairOrders: [], parts: [], partsOrders: [], cores: [], stdJobs: [], importHistory: [], importTemplates: [], msgConsents: [], messageLog: [], serviceSettings: {}, technicians: [], appointments: [], payables: [], bankRec: {}, audit: [], bodyShopROs: [], checks: [], // Blank check config: no demo bank name / check number — set in Settings. vendors: [], checkConfig: { nextNum: 1, bankAcct: "202", bankName: "" }, bulkFluids: [], users: [], employees: [], policyAdjustments: [], warrantyReceivables: [], factoryReceivables: [], commissions: [], floorplanPayoffsDue: [], lenderSubmissions: [], arCustomers: [], arLetters: [], appraisals: [], customers: {}, crmLeads: [], deskScenarios: [], glAccounts: [], journalEntries: [], // configuration carried over from the seed (not "data") arDefaults: JSON.parse(JSON.stringify(DMS_SEED.arDefaults)), arAccountTypes: JSON.parse(JSON.stringify(DMS_SEED.arAccountTypes)), arTaxCodes: JSON.parse(JSON.stringify(DMS_SEED.arTaxCodes)), arDunningMessages: JSON.parse(JSON.stringify(DMS_SEED.arDunningMessages)), arLetterTemplates: JSON.parse(JSON.stringify(DMS_SEED.arLetterTemplates)), }; // RC35 (brd, D36): the sample data / legacy sample / demo mode are the primary store's only const DMS_PRIMARY_ONLY = "The sample data belongs to the first store only — it is not loaded in this store."; const dmsPrimaryStore = () => !(window.K2BStore && window.K2BStore.pageId() && !window.K2BStore.isPrimary()); const _blank = () => { const b = JSON.parse(JSON.stringify(DMS_BLANK)); // RC35 (brd, SPEC 1.8): the tuned A/R tax codes / defaults are the primary store's; another store starts with none ("No tax codes yet") if (window.K2BStore && window.K2BStore.pageId() && !window.K2BStore.isPrimary()) { b.arTaxCodes = []; b.arDefaults = {}; b.arAccountTypes = []; b.arDunningMessages = []; b.arLetterTemplates = []; } return b; }; // ── Bulk oil / fluid tanks (Service & Parts buy oil in bulk; ROs dispense by // the quart). Defaults model a typical Chevy store's three tanks. ── const BULK_FLUIDS_DEFAULT = [ { id: "BF-5W30", name: "5W-30 Synthetic Blend (dexos1)", uom: "qt", onHand: 386, capacity: 660, reorderAt: 160, cost: 1.85, sale: 7.99, vendor: "OZARK OIL CO.", tank: "Tank 1" }, { id: "BF-0W20", name: "0W-20 Full Synthetic (dexos1)", uom: "qt", onHand: 118, capacity: 440, reorderAt: 120, cost: 2.40, sale: 8.99, vendor: "OZARK OIL CO.", tank: "Tank 2" }, { id: "BF-15W40", name: "15W-40 Diesel (CK-4)", uom: "qt", onHand: 232, capacity: 330, reorderAt: 80, cost: 2.10, sale: 8.49, vendor: "OZARK OIL CO.", tank: "Tank 3" }, ]; const DMS = (() => { if (typeof window !== "undefined" && window.DMS) return window.DMS; const listeners = new Set(); let state; // Turn a stored plaintext JSON string into a migrated state object. const parseRaw = (raw) => { try { if (raw) { const s = JSON.parse(raw); // Non-destructive backfill: if a saved state is missing any key added in // a later build, copy it from the BLANK template (config + empty arrays) // so new modules aren't broken — without injecting demo data. const blank0 = _blank(); // RC35 (brd): the store's own blank (no primary-store A/R tables in another store) for (const k in DMS_BLANK) { if (s[k] === undefined) s[k] = blank0[k]; } // Older builds mirrored the local login registry (plaintext passwords) // into state.users. That mirror is gone (C-02c) — scrub any leftover // copy so it never persists or syncs again. s.users = []; // Map-type collections must be objects: an older build's bootstrap // stored customers as an Array (sparse, keyed by custNum) — rebuild // the map so every record is reachable, flushable and small (R-2/R-6). for (const col of Object.keys(SERVER_COLLECTIONS)) { const def = SERVER_COLLECTIONS[col]; if (def.map && (Array.isArray(s[col]) || !s[col] || typeof s[col] !== "object")) s[col] = _colToMap(s[col], def.idField); } return s; } } catch (e) {} return _blank(); }; const vault = () => (typeof window !== "undefined" ? window.JBVault : null); const isEnc = (raw) => !!(vault() && vault().isEnvelope && vault().isEnvelope(raw)); let lockedVault = false; // true when data is encrypted at rest but not yet decrypted const load = () => { let raw = null; try { raw = localStorage.getItem(dmsKey()); } catch (e) {} if (isEnc(raw)) { // Encrypted at rest. If the vault is unlocked we can't decrypt // synchronously here — caller must use loadAsync()/hydrate(). Signal lock. lockedVault = !(vault() && vault().isUnlocked()); return { __locked: true }; } lockedVault = false; return parseRaw(raw); }; // Async load that decrypts when the data is encrypted and the vault is open. const loadAsync = async () => { let raw = null; try { raw = localStorage.getItem(dmsKey()); } catch (e) {} if (isEnc(raw)) { if (!(vault() && vault().isUnlocked())) { lockedVault = true; return null; } try { const plain = await vault().decrypt(raw); lockedVault = false; return parseRaw(plain); } catch (e) { lockedVault = true; return null; } } lockedVault = false; return parseRaw(raw); }; // RC31 (fx31, RC30 acceptance F6): the search boxes' customer index — every OLD number (alias) and every phone of the station's customers → the // LIVE customer #. Rebuilt lazily after any change (notify bumps ver); `server` holds the server's answer for a typed number the station had none for. const _custQ = { ver: 0, idx: null, server: new Map() }; const custQueryIndex = () => { const m = state.customers || {}; const n = Object.keys(m).length; const ix0 = _custQ.idx; if (ix0 && ix0.ref === m && ix0.n === n && ix0.ver === _custQ.ver) return ix0; const alias = new Map(), phone = new Map(); const CNx = window.CustNum; for (const k of Object.keys(m)) { const c = m[k]; if (!c) continue; const cn = String(c.custNum || k); (Array.isArray(c.aliases) ? c.aliases : []).forEach((a) => { const s = String(a).trim(); if (s && !alias.has(s) && !m[s]) alias.set(s, cn); }); (CNx && CNx.phonesOfRecord ? CNx.phonesOfRecord(c) : []).forEach((p) => { const l = phone.get(p) || []; if (l.indexOf(cn) === -1) l.push(cn); phone.set(p, l); }); } _custQ.idx = { ref: m, n, ver: _custQ.ver, alias, phone, memo: new Map() }; return _custQ.idx; }; const notify = () => { _custQ.ver++; listeners.forEach(fn => { try { fn(); } catch (e) {} }); }; // Every connection-state change also re-renders subscribers (banner, etc.). connListeners.add(() => notify()); state = load(); /* ══════════════════════════════════════════════════════════════════════ MULTI-USER LIVE LAYER (server-backed) Persistence flows through the JBREWS DMS v2 API on the same origin (nginx proxies /api/ + /ws to the Node server on :3001). • persist() snapshots the dirty collections, diffs against last-sent snapshot, and POSTs changed records to /api/ (debounced). • On failure, writes the full state to localStorage as offline fallback; it will replay on the next successful persist() once the server is back. • A WebSocket pushes {type:'write'} from other stations; we refetch the single affected record and merge into state. ══════════════════════════════════════════════════════════════════════ */ const PRESENCE_TTL = 12000, LOCK_TTL = 30000; const PRESENCE_KEY = "jbrews-presence-live", LOCK_KEY = "jbrews-locks"; const SESSION_ID = __sessionId; const STATION = (() => { let h = 0; for (const c of SESSION_ID) h = (h * 31 + c.charCodeAt(0)) >>> 0; return "Station " + (10 + (h % 89)); })(); const remoteListeners = new Set(); const presenceListeners = new Set(); const fireRemote = (meta) => remoteListeners.forEach(fn => { try { fn(meta); } catch (e) {} }); const firePresence = () => presenceListeners.forEach(fn => { try { fn(); } catch (e) {} }); // ── dirty-tracking + debounced flush ──────────────────────────────── // Snapshot of last-sent record JSON for each (collection,recordId). We diff // current state against this on every persist() to figure out what changed. const lastSent = new Map(); // key "collection|id" -> JSON string // Server revision last seen per record — sent back as baseRev on every push // so the server can refuse a stale write (409) instead of clobbering someone // else's change. 0 / missing = "we believe this record is new". const lastRev = new Map(); // key "collection|id" -> number let flushTimer = null; let flushing = false; let pendingReFlush = false; let presenceUsers = []; // "Work Offline Anyway" flag (C-03b) — see api.setOfflineByChoice. let __offlineByChoice = false; // ── Server team roster (read-only, ALL roles) ─────────────────────── // Cached list of active server users {id,name,role} for salesperson // dropdowns etc. Populated after a successful bootstrap and refreshed by // the 5-minute safety timer via GET /api/team/roster (the roster add-on, // readable by any authenticated user — unlike admin-only /team/list). // On any failure the PRIOR roster is kept; this never throws. let __teamRoster = []; async function refreshRoster() { if (!AUTH_TOKEN) return; try { const r = await apiGet("/team/roster"); if (r && r.ok && Array.isArray(r.users)) { const next = r.users.map(u => ({ id: String(u.id || ""), name: u.name || String(u.id || ""), role: u.role || "" })); if (JSON.stringify(next) !== JSON.stringify(__teamRoster)) { __teamRoster = next; notify(); // dropdowns re-render with the fresh roster } } } catch (e) { /* keep prior roster — never throw */ } } const recKey = (col, id) => col + "|" + id; // ── Three-way merge (multi-user safety) ───────────────────────────── // base = what this station last saw from the server (lastSent snapshot) // mine = this station's current copy theirs = the server's current copy // Field by field (top level): a field only WE changed keeps our value; a // field only THEY changed takes theirs; a field BOTH changed differently is // a true conflict → theirs (the server) wins and the field is reported so // the UI can say so. Fields neither touched are identical anyway. const _same = (a, b) => JSON.stringify(a === undefined ? null : a) === JSON.stringify(b === undefined ? null : b); function threeWayMerge(base, mine, theirs) { base = base || {}; mine = mine || {}; theirs = theirs || {}; const out = Object.assign({}, theirs); const conflicts = []; const keys = new Set([...Object.keys(mine), ...Object.keys(base)]); for (const k of keys) { const iChanged = !_same(mine[k], base[k]); if (!iChanged) continue; // theirs (or unchanged) stands const theyChanged = !_same(theirs[k], base[k]); if (!theyChanged) { if (mine[k] === undefined) delete out[k]; else out[k] = mine[k]; } else if (!_same(mine[k], theirs[k])) conflicts.push(k); // both changed → theirs kept } return { merged: out, conflicts }; } const _conflictToastAt = new Map(); // (ledger lane / ldg) The server refused a financial write — unbalanced / unknown account / closed month / posted entry changed / // version number missing / role / an older page — and handed back its own copy of the record (none when the record never // existed there). The books on this station go back to what the server has, the person sees the reason in plain English, // and nothing invalid stays local: it is NOT kept as a "sync issue" to retry (recordFinancialRefusal keeps the reason only). // A 409 "conflict" with the server copy (someone else changed the record first) takes the same path: the saved copy is shown. function dmsLedgerRefused(col, id, body, status) { const k = recKey(col, id); const def = SERVER_COLLECTIONS[col]; const msg = (body && body.error) ? String(body.error) : "The server refused this change."; if (body && body.record !== undefined && body.record !== null) { setStateRecord(col, id, body.record); // the books as the server has them lastSent.set(k, JSON.stringify(body.record)); if (body.rev != null) lastRev.set(k, Number(body.rev)); } else if (def && !def.singleton) { // a NEW record the server would not take: it exists nowhere now if (def.map) { if (state[col] && !Array.isArray(state[col])) delete state[col][String(id)]; } else if (Array.isArray(state[col])) state[col] = state[col].filter(r => !(r && String(_recId(def, r)) === String(id))); lastSent.delete(k); lastRev.delete(k); } else { lastSent.set(k, JSON.stringify(getStateRecord(col, id) || {})); // a singleton with no server copy: stop retrying it } recordFinancialRefusal(col, id, status, body); try { if (typeof window !== "undefined" && window.k2bToast) window.k2bToast("⚠ " + msg, "error", 9000); } catch (e) {} try { if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent("k2b-ledger-refused", { detail: { collection: col, id, status, error: msg, code: body && body.code } })); } catch (e) {} persist(); notify(); } function announceConflict(col, id, conflicts, by) { if (!conflicts.length || typeof window === "undefined" || !window.k2bToast) return; const k = recKey(col, id); const now = Date.now(); if ((_conflictToastAt.get(k) || 0) > now - 8000) return; // one toast per record per 8s _conflictToastAt.set(k, now); window.k2bToast(`⚠ ${id} was changed on another station${by ? " (" + by + ")" : ""} at the same time — their ${conflicts.slice(0, 3).join(", ")} kept; everything else merged.`, "warn", 6500); } function getStateRecord(col, id) { const def = SERVER_COLLECTIONS[col]; if (!def) return null; if (def.singleton) return state[col] || null; if (def.map) return (state[col] && !Array.isArray(state[col]) && state[col][String(id)]) || null; return (Array.isArray(state[col]) ? state[col] : []).find(r => r && String(_recId(def, r)) === String(id)) || null; } // Put a merged record into state (map / list / singleton) without touching // lastSent (caller decides whether it is still dirty). function setStateRecord(col, id, record) { const def = SERVER_COLLECTIONS[col]; if (!def) return; if (def.singleton) { state[col] = record || {}; return; } if (def.map) { if (!state[col] || typeof state[col] !== "object" || Array.isArray(state[col])) state[col] = _colToMap(state[col], def.idField); state[col][String(id)] = record; return; } state[col] = Array.isArray(state[col]) ? state[col] : []; const ix = state[col].findIndex(r => r && String(_recId(def, r)) === String(id)); if (ix >= 0) state[col][ix] = record; else state[col].unshift(record); } // ── (sales features H6) Two PCs gave a NEW repair order / appointment the same id ── // Each new RO / appointment carries a creation nonce `_cid`. When the server (409) or a live // update shows a DIFFERENT record (other `_cid`) under the id of one of OUR records that never // reached the server, ours is re-numbered (RO: next free number; appointment: new uid), the cross // link appointment.roId <-> ro.apptId follows it, their record is kept under the old id, and // nothing is merged or deleted on the server. function dmsIsIdCollision(col, mine, theirs) { if (col !== "repairOrders" && col !== "appointments") return false; if (!mine || !theirs || typeof mine !== "object" || typeof theirs !== "object") return false; if (!mine._cid) return false; // a record made before this update: old merge path return theirs._cid !== mine._cid; } function dmsRekeyCollision(col, oldId, mine, theirs, theirRev) { const list = Array.isArray(state[col]) ? state[col] : (state[col] = []); const ix = list.findIndex(r => r && r === mine); const ours = ix >= 0 ? list[ix] : (getStateRecord(col, oldId) || mine); const newId = col === "repairOrders" ? api._nextRoId() : api._uid("APT"); ours.id = newId; if (ix < 0 && !list.includes(ours)) list.unshift(ours); if (col === "repairOrders" && ours.apptId) { const a = (state.appointments || []).find(x => x && x.id === ours.apptId && x.roId === oldId); if (a) a.roId = newId; } if (col === "appointments") { (state.repairOrders || []).forEach(ro => { if (ro && ro.apptId === oldId && (ours.roId ? ro.id === ours.roId : !lastSent.has(recKey("repairOrders", ro.id)))) ro.apptId = newId; }); } lastSent.delete(recKey(col, oldId)); mergeRecord(col, oldId, theirs); // their record stays under the old id (not dirty) if (theirRev != null) lastRev.set(recKey(col, oldId), Number(theirRev)); const label = (id) => String(id).replace(/^RO-/, "RO "); if (typeof window !== "undefined") { if (window.k2bToast) window.k2bToast(col === "repairOrders" ? `Two people opened ${label(oldId)} at the same moment. Yours is now ${label(newId)}.` : `Two people booked appointment ${oldId} at the same moment. Yours is now ${newId}.`, "warn", 7000); try { window.dispatchEvent(new CustomEvent("k2b-rekeyed", { detail: { collection: col, from: oldId, to: newId } })); } catch (e) {} } notify(); return newId; } // Build a snapshot of all server-known collections from current state. function snapshotCurrent() { const snap = { lists: {}, singletons: {} }; for (const col of Object.keys(SERVER_COLLECTIONS)) { const def = SERVER_COLLECTIONS[col]; if (def.singleton) { snap.singletons[col] = state[col] || {}; } else { // map-type collections snapshot their VALUES (never Array indices). const list = def.map ? _colRows(state[col]) : (Array.isArray(state[col]) ? state[col] : []); const map = new Map(); for (const rec of list) { if (!rec || typeof rec !== "object") continue; const id = _recId(def, rec); if (id == null || id === "") continue; map.set(String(id), rec); } snap.lists[col] = map; } } return snap; } // Compute the set of (collection,recordId) records whose JSON differs from // the last successfully-sent version. Also tracks which records disappeared. function diffAgainstLastSent() { const snap = snapshotCurrent(); const changed = []; // { col, id, record } const deletes = []; // { col, id } const seen = new Set(); for (const col of Object.keys(SERVER_COLLECTIONS)) { const def = SERVER_COLLECTIONS[col]; // Sales / role-less sessions may not write the server audit collection // (403 → poison record). Keep audit local for them; queue nothing. if (col === "audit" && !auditSyncAllowed()) continue; if (def.singleton) { const id = "__singleton__"; const k = recKey(col, id); seen.add(k); const json = JSON.stringify(snap.singletons[col] || {}); // An EMPTY singleton the server has never sent is nothing to push. if (json === "{}" && lastSent.get(k) === undefined) continue; // The check-writing config at its built-in blank default, on a server that has no checkConfig row, is nothing to push // either: every station starts from that same default, and only an office role may write the collection (roles.js) - // pushing it from a Sales / Sales Manager station only earned a 403 and a "1 record could not be saved … tell an admin" // warning at every sign-in on a fresh database. The first real change (Check Writing settings saved, a check printed) // differs from the default and is pushed as before. if (col === "checkConfig" && (lastSent.get(k) === undefined || lastSent.get(k) === "{}") && json === JSON.stringify(DMS_BLANK.checkConfig || {})) continue; if (lastSent.get(k) !== json) changed.push({ col, id, record: snap.singletons[col] || {} }); } else { const map = snap.lists[col]; for (const [id, rec] of map) { const k = recKey(col, id); seen.add(k); const json = JSON.stringify(rec); if (lastSent.get(k) !== json) changed.push({ col, id, record: rec }); } } } for (const k of lastSent.keys()) { if (!seen.has(k)) { const [col, id] = k.split("|"); if (col === "audit" && !auditSyncAllowed()) continue; if (SERVER_COLLECTIONS[col] && !SERVER_COLLECTIONS[col].singleton) { deletes.push({ col, id }); } } } return { changed, deletes }; } // H11 (sales features) — a shared PC: the saved copy belongs to ONE person. // Every write of DMS_KEY also writes DMS_WHO_KEY = the signed-in user id // (the token's sub; after a token expired mid-session, the same session's // user id, so their own unsent edits are still theirs at the next sign-in). // A write while nobody is signed in leaves the label as it is (a copy kept // at Sign Off because something was not sent stays that person's). // loadFromServer never merges or pushes a copy that belongs to someone else. function dmsCacheOwner() { const ti = decodeTokenInfo(); if (ti && ti.sub) return String(ti.sub); try { return (currentUser && currentUser.user) ? String(currentUser.user) : ""; } catch (e) { return ""; } } // Other localStorage keys that can hold record data in plaintext: drafts and // hand-off pointers the screens write (desk worksheet with FICO/income, form // drafts, deal/unit/customer pointers, journal drafts). They are cleared when // the person signs off and when another person's copy is replaced at sign-in. // Settings and registry keys (lenders, products, forms, desk settings, vault // config, integration keys) are deliberately kept. (blocker 4, lane "cache") const LOCAL_WORK_KEYS = [ "jbrews-form-data-v2", "jb_desk_deal", "jb_desk_unit", "jb_desk_work", "jb_fw_deal", "jb_deal_focus", "jb_crm_cust", "jb_cust_focus", "jb_credit_cust", "jb_trade_focus", "jb_ro_focus", "jb_recon_focus", "jb_inv_focus", "jb_appraisal_lead", "jb_trade_lead", "jb_co_tab", "jb_body_focus", // RC26 (inv): + the body-shop job pointer "jbrews-acct-session-je-v1", "jbrews-acct-journal-v1", ]; function clearLocalWork() { for (const k of LOCAL_WORK_KEYS) { try { localStorage.removeItem(k); } catch (e) {} } try { sessionStorage.removeItem("dkf_view_deal"); } catch (e) {} try { delete window.jbDeskDeal; delete window.jbDeskUnit; delete window.jbAppraisalLead; delete window.dkfViewDeal; } catch (e) {} } // ── THE ONE WRITE PATH for this PC's saved copy (DMS_KEY) ───────────── // Blocker 4: "an ordinary save replaced the encrypted workstation cache with // plaintext". Every write of the copy goes through cacheWrite(): // (a) encryption at rest ON + vault open → the copy is written as an AES-256-GCM envelope // (b) encryption at rest ON + vault locked → nothing is written; the person is told ONCE per locked period // (c) the stored copy is an envelope and the vault is not open → it is never overwritten (no downgrade) // (d) encryption never turned on → plaintext, as before // Only disableEncryption() may replace an envelope with plaintext ({ downgrade: true }, audited). // Encryption is asynchronous: the state is snapshotted synchronously, encrypted, // and the NEWEST snapshot wins — an older encryption result never overwrites a newer one. let __cacheSeq = 0; let __lockedTold = false; // (b) one message per locked period let __lastCacheWrite = null; // { ts, mode } for the Security screen and tests const vaultCfgEnabled = () => { // the setting itself — read directly if the crypto module failed to load const v = vault(); if (v && typeof v.isEnabled === "function") return !!v.isEnabled(); try { const c = JSON.parse(localStorage.getItem("jbrews-vault-cfg") || "null"); return !!(c && c.enabled); } catch (e) { return false; } }; const vaultOpen = () => { const v = vault(); return !!(v && typeof v.isUnlocked === "function" && v.isUnlocked()); }; const storedCacheKind = () => { let raw = null; try { raw = localStorage.getItem(dmsKey()); } catch (e) {} return raw == null ? "none" : (isEnc(raw) ? "encrypted" : "plaintext"); }; function rawCacheWrite(raw, who) { localStorage.setItem(dmsKey(), raw); try { if (who) localStorage.setItem(dmsWhoKey(), who); } catch (e) {} } function tellLocked() { if (__lockedTold) return; __lockedTold = true; try { if (typeof window !== "undefined" && window.k2bToast) window.k2bToast("This PC's saved copy is encrypted and locked, so your changes are not being saved on this PC until it is unlocked (they still go to the server while you are online). Reload and enter the data passphrase to unlock it.", "warn", 9000); } catch (e) {} } function cacheWrite(opts) { opts = opts || {}; if (!state || state.__locked) return Promise.resolve({ ok: false, reason: "locked-placeholder" }); // never persist the "locked" placeholder let json; try { json = JSON.stringify(state); } catch (e) { return Promise.resolve({ ok: false, reason: "stringify" }); } const who = dmsCacheOwner(); const seq = ++__cacheSeq; const encOn = vaultCfgEnabled(); if (encOn && vaultOpen()) { // (a) return vault().encrypt(json).then((env) => { if (seq !== __cacheSeq) return { ok: false, reason: "superseded" }; rawCacheWrite(env, who); __lockedTold = false; __lastCacheWrite = { ts: Date.now(), mode: "encrypted" }; return { ok: true, mode: "encrypted" }; }).catch(() => { __lastCacheWrite = { ts: Date.now(), mode: "refused-encrypt-failed" }; return { ok: false, reason: "encrypt-failed" }; }); } if ((encOn || storedCacheKind() === "encrypted") && !opts.downgrade) { // (b) + (c) tellLocked(); __lastCacheWrite = { ts: Date.now(), mode: "refused-locked" }; return Promise.resolve({ ok: false, reason: "locked" }); } try { rawCacheWrite(json, who); __lastCacheWrite = { ts: Date.now(), mode: "plaintext" }; return Promise.resolve({ ok: true, mode: "plaintext" }); } // (d) catch (e) { return Promise.resolve({ ok: false, reason: "write-failed" }); } } // Write the entire state to localStorage as an offline cache — through the one path above. function offlineCacheWrite() { try { cacheWrite().catch(() => {}); } catch (e) {} } // Actually send queued changes to the server. Single-flight. // // Failure taxonomy (per record): // • fetch throw / 5xx → HARD failure: record stays dirty, flush stops, // connState → "offline", silent retry on the next flush. // • 401 → apiFetch already cleared the token and flipped // connState to "auth-expired"; treated like a hard failure (stop, keep // dirty) but the auth-expired state is never downgraded to "offline". // • 400/403/404/422 → PERMANENT rejection of THAT record only: drop it // from the dirty queue (poison-record protection), log it in // __syncIssues, keep going. Never changes connState, never auto-retried // (a later user edit of the record makes it dirty again naturally). // • other 4xx (409/429/…) → record stays dirty for retry, but never // changes connState. // connState → "online" when the round had any successful push OR nothing // needed pushing at all. A round of only per-record 4xx leaves it unchanged. async function flushNow() { if (flushing) { pendingReFlush = true; return; } if (__offlineByChoice) { // "Work Offline Anyway" — the user chose a local session. Never push // under whatever (possibly stale) token is lying around; cache locally. offlineCacheWrite(); return; } flushing = true; try { const { changed, deletes } = diffAgainstLastSent(); if (!changed.length && !deletes.length) { // Zero attempts needed — an empty queue is a healthy queue. if (AUTH_TOKEN) setConnState("online"); return; } let hardFail = false; // network / 5xx / 401 — stop and retry later let pushedCount = 0; let issuesAdded = false; for (const ch of changed) { let json = JSON.stringify(ch.record); try { if (!AUTH_TOKEN) throw new Error("no token"); const k = recKey(ch.col, ch.id); let record = ch.record; let baseRev = lastRev.has(k) ? lastRev.get(k) : 0; // The common ancestor for a conflict merge is what we had last seen // from the server WHEN THIS PUSH STARTED — a live update may move // lastSent while the request is in flight. const baseAtSend = lastSent.has(k) ? lastSent.get(k) : null; let r = null; let rekeyed = false; // (sales features H6) our new RO / appointment got another PC's id // Up to 3 attempts: a 409 means someone else saved this record since // we last saw it → merge their copy with our edits and push again. for (let attempt = 0; attempt < 3; attempt++) { try { r = await apiPost("/" + ch.col, { record, baseRev }); break; } catch (e409) { if (!(e409 && e409.status === 409 && e409.body)) throw e409; if (isFinalRefusal(ch.col, e409.body)) throw e409; // (ledger lane) the server REFUSED a financial write and said why: never merged, handled below const theirs = e409.body.record; const base = baseAtSend ? JSON.parse(baseAtSend) : null; if (theirs === null || theirs === undefined) { baseRev = e409.body.rev || 0; continue; } // row vanished (deleted) → create fresh // Merge against our CURRENT copy (a live update may already have // been folded in since the push started), not the stale payload. const mineNow = getStateRecord(ch.col, ch.id) || record; // (sales features H6) A record we never sent that meets a DIFFERENT record (other creation // nonce) under the same id = two PCs numbered a new RO / appointment the same: re-number ours, // keep theirs, never merge the two. if (baseAtSend === null && dmsIsIdCollision(ch.col, mineNow, theirs)) { dmsRekeyCollision(ch.col, ch.id, mineNow, theirs, e409.body.rev); rekeyed = true; break; } const { merged, conflicts } = threeWayMerge(base, mineNow, theirs); announceConflict(ch.col, ch.id, conflicts, null); record = merged; setStateRecord(ch.col, ch.id, merged); // our state now holds the merge lastSent.set(k, JSON.stringify(theirs)); // their copy is the new base baseRev = e409.body.rev; notify(); } } if (rekeyed) { pendingReFlush = true; continue; } // the re-numbered copy goes up on the next round if (!r) throw Object.assign(new Error("conflict retries exhausted"), { status: 409 }); json = JSON.stringify(record); lastSent.set(k, json); if (r && r.rev != null) lastRev.set(k, Number(r.rev)); pushedCount++; } catch (e) { const st = e && e.status; if (e && e.body && isFinalRefusal(ch.col, e.body)) { // (ledger lane / ldg) The server refused this financial record (journal entry, check, chart, period, A/R …) with a // plain-English reason. It is NOT kept locally as a silent "sync issue": the server's copy comes back (a new // record goes away), the person is told, and the queue moves on. dmsLedgerRefused(ch.col, ch.id, e.body, st); issuesAdded = true; } else if (isPermanentStatus(st)) { // Poison record: server understood the request and said no. // Un-dirty it (as-sent snapshot = current) so it can't wedge the // queue, and log it for the Settings screen. NOT a connection // problem — connState untouched. lastSent.set(recKey(ch.col, ch.id), json); recordSyncIssue(ch.col, ch.id, st, e.message); issuesAdded = true; } else if (st && st >= 400 && st < 500 && st !== 401) { // Transient-ish 4xx (409/429/…): keep dirty, retry next flush, // never flips the connection state. } else { hardFail = true; // fetch throw, 5xx, or 401 (already handled) break; } } } if (!hardFail) { for (const d of deletes) { try { const dr = await apiDelete("/" + d.col + "/" + encodeURIComponent(d.id)); lastSent.delete(recKey(d.col, d.id)); pushedCount++; // RC27 (dsk) fix round: an internal RO of a we-owe was deleted — the server put the we-owe back on the open list; say so if (dr && Array.isArray(dr.weOweIds) && dr.weOweIds.length) { try { if (dr.message && window.k2bToast) window.k2bToast(dr.message, "warn", 8000); if (api._weOwes && api._weOwes.at) api.loadWeOwes(api._weOwes.status || "open"); } catch (e2) {} } } catch (e) { const st = e && e.status; if (e && e.body && isFinalRefusal(d.col, e.body)) { dmsLedgerRefused(d.col, d.id, e.body, st); // (ledger lane) e.g. a posted entry / a written check: it comes back, with the reason issuesAdded = true; } else if (st === 404) { // Already gone server-side — goal state reached. lastSent.delete(recKey(d.col, d.id)); pushedCount++; } else if (isPermanentStatus(st)) { lastSent.delete(recKey(d.col, d.id)); recordSyncIssue(d.col, d.id, st, e.message); issuesAdded = true; } else if (st && st >= 400 && st < 500 && st !== 401) { // keep queued for retry; connState untouched } else { hardFail = true; break; } } } } if (hardFail) { offlineCacheWrite(); // fallback so a refresh restores // A 401 mid-flush already flipped us to "auth-expired" (apiFetch); // don't downgrade that to plain "offline". setConnState fires the // one-per-transition failure toast itself; silent otherwise. if (__connState !== "auth-expired") setConnState("offline"); } else if (pushedCount > 0) { setConnState("online"); } // else: only per-record 4xx this round — connState stays as it was. if (issuesAdded) notify(); // let Settings re-render its issues line } finally { flushing = false; if (pendingReFlush) { pendingReFlush = false; setTimeout(flushNow, 50); } } } const persist = () => { if (lockedVault) return; // Always keep a localStorage copy as offline fallback — through the one write path. offlineCacheWrite(); if (flushTimer) clearTimeout(flushTimer); flushTimer = setTimeout(() => { flushTimer = null; flushNow(); }, 250); }; // Hydrate state from /api/bootstrap. Falls back to localStorage on failure. // Default behavior is a NON-DESTRUCTIVE merge: records that exist only on // this workstation (offline work) are kept — and left dirty so they flush // straight back to the server (auto-recovery of "trapped" local data). // Pass { replace: true } for server-driven bulk events (reset / import) // where the server copy must win outright. async function loadFromServer(opts) { opts = opts || {}; if (!AUTH_TOKEN) return { ok: false, reason: "no-token" }; // H11: whose saved copy is on this PC? Read before the request too, so a // save that happens while the bootstrap is in flight cannot re-label it. const readCacheWho = () => { try { return localStorage.getItem(dmsWhoKey()); } catch (e) { return null; } }; const whoAtStart = readCacheWho(); setConnState("connecting"); try { // (ldg, spec 16.1) an older page must not merge and push its books: ask the server what it accepts first try { const h = await apiGet("/health"); if (h && Number(h.minProtocol) > DMS_PROTOCOL) dmsStaleReload({ error: "This station is running an older DMS. Reload the DMS (Ctrl+F5).", minProtocol: h.minProtocol }); else dmsProtocolOk(); } catch (e) { /* the bootstrap below answers for the connection state */ } const boot = await apiGet("/bootstrap"); // RC35 (brd, SPEC 4.1 step 3): the server's store must be the store this page loaded - otherwise nothing is merged or pushed if (boot && boot.store && window.K2BStore) { const ps = window.K2BStore.pageId(); if (ps && String(boot.store.id) !== ps) { setConnState("offline", { quiet: true }); dmsStoreReload("mismatch"); return { ok: false, reason: "store-mismatch" }; } window.K2BStore._answer({ store: boot.store, stores: boot.stores, multiStore: boot.multiStore }); window.K2BStore._setLast(boot.store.id); try { ["k2b-store-upgrade", "k2b-store-reload-adopt", "k2b-store-reload-mismatch", "k2b-store-reload-store"].forEach((k) => sessionStorage.removeItem(k)); } catch (e) {} } if (boot && boot.me) dmsOwnSetMe(boot.me); // RC22 (own): owner / per-user rights try { setTimeout(() => { try { api.loadPayPlan(); } catch (e) {} }, 0); } catch (e) {} // RC28 (fx): the server's pay plan (never a per-station copy) // H11 (b): a copy that belongs to someone else — or to nobody known (the // first boot after this update) — is replaced by what the server sends // this person: nothing of the previous person is merged or pushed. const me = String((decodeTokenInfo() || {}).sub || ""); const whoNow = readCacheWho(); const replace = !!opts.replace || !me || whoAtStart === null || whoAtStart !== me || whoNow === null || whoNow !== me; // (blocker 4) a copy labelled with ANOTHER person's id: their drafts and hand-off pointers on this // PC go too. (No label = nothing is known about them; a pop-out booting while the vault is locked // must not clear the pointers the main window is using.) if (me && whoAtStart && whoAtStart !== me) clearLocalWork(); // H11 (c): records the server keeps from this login (another // salesperson's lead, appraisals, desk worksheets …) are dropped here, // never kept as "local-only" and pushed back. "*" = every local record of // that collection the server did not send. const withheld = (boot && boot.withheld && typeof boot.withheld === "object") ? boot.withheld : {}; const isWithheld = (col, id) => { const w = withheld[col]; if (w === "*") return true; return Array.isArray(w) && w.some(x => String(x) === String(id)); }; const prev = state; // what this workstation had const prevSent = new Map(lastSent); // what this workstation last saw from the server const next = _blank(); const serverCopy = {}; // exactly what the server sent const cols = boot && boot.collections ? boot.collections : {}; const bootRevs = (boot && boot.revs && typeof boot.revs === "object") ? boot.revs : {}; for (const col of Object.keys(SERVER_COLLECTIONS)) { const def = SERVER_COLLECTIONS[col]; if (def.singleton) { // A singleton the server does not have (or an older server that does not // know the collection) is EMPTY — never a blank default, or the merge // below would let that default overwrite this station's real settings. serverCopy[col] = (cols[col] && typeof cols[col] === "object") ? cols[col] : {}; next[col] = Object.keys(serverCopy[col]).length ? serverCopy[col] : (_blank()[col] || {}); } else if (def.map) { // server sends a list (or, from an older server, an object) → state MAP serverCopy[col] = _colRows(cols[col]); next[col] = _colToMap(serverCopy[col], def.idField); } else { serverCopy[col] = Array.isArray(cols[col]) ? cols[col] : []; next[col] = serverCopy[col].slice(); } } // Merge in local-only records (unless replace requested). Server wins on // any id conflict; purely-local records survive and stay dirty. let localOnly = 0; // Records this station edited but never got to the server (offline / // in flight) are MERGED with the server copy field-by-field instead of // being thrown away: our edits stay, their newer fields come in. const dirtyMerged = []; const mergeDirty = (col, id, localRec, srvRec) => { const k = recKey(col, id); const sentJson = prevSent.get(k); if (sentJson === undefined || sentJson === JSON.stringify(localRec)) return srvRec; // not dirty → server wins if (JSON.stringify(srvRec) === sentJson) return localRec; // server unchanged → keep ours (still dirty) const { merged, conflicts } = threeWayMerge(JSON.parse(sentJson), localRec, srvRec); if (conflicts.length) dirtyMerged.push({ col, id, conflicts }); return merged; }; if (!replace && prev && !prev.__locked) { for (const col of Object.keys(SERVER_COLLECTIONS)) { const def = SERVER_COLLECTIONS[col]; if (def.singleton) { const srvEmpty = !serverCopy[col] || Object.keys(serverCopy[col]).length === 0; const locHas = prev[col] && typeof prev[col] === "object" && Object.keys(prev[col]).length > 0; // blkA (access): a singleton the server WITHHOLDS from this login (withheld[col] === "*") is dropped like a withheld list // record (H11 (c)) - never kept as local-only and pushed back (the server would refuse it and log a sync issue). if (srvEmpty && locHas) { if (!isWithheld(col, "__singleton__")) { next[col] = prev[col]; localOnly++; } } else if (locHas) next[col] = mergeDirty(col, "__singleton__", prev[col], next[col]); } else if (def.map) { for (const rec of _colRows(prev[col])) { const id = rec && _recId(def, rec); if (id == null || id === "") continue; if (next[col][String(id)]) { next[col][String(id)] = mergeDirty(col, String(id), rec, next[col][String(id)]); continue; } if (isWithheld(col, id)) continue; // H11 (c) next[col][String(id)] = rec; localOnly++; } } else { const have = new Map(next[col].map((r, i) => [(r && _recId(def, r) != null) ? String(_recId(def, r)) : null, i])); for (const rec of (Array.isArray(prev[col]) ? prev[col] : [])) { const id = rec && _recId(def, rec); if (id == null || id === "") continue; if (have.has(String(id))) { const i = have.get(String(id)); next[col][i] = mergeDirty(col, String(id), rec, next[col][i]); continue; } if (isWithheld(col, id)) continue; // H11 (c) next[col].push(rec); localOnly++; } } } } state = next; lastSent.clear(); lastRev.clear(); for (const col of Object.keys(bootRevs)) { const m = bootRevs[col] || {}; for (const id of Object.keys(m)) lastRev.set(recKey(col, id), Number(m[id])); } // Seed lastSent from the SERVER copy only, so future diffs see actual // changes — and the merged local-only records read as dirty (they will // flush on the next persist()). for (const col of Object.keys(SERVER_COLLECTIONS)) { const def = SERVER_COLLECTIONS[col]; if (def.singleton) { // blkA (access): a withheld singleton sits in state as the blank default; seed lastSent with that same default so the // blank is not read as a change this station must push (the server refuses writes of what it withholds). lastSent.set(recKey(col, "__singleton__"), JSON.stringify(isWithheld(col, "__singleton__") ? (next[col] || {}) : (serverCopy[col] || {}))); } else { for (const rec of (serverCopy[col] || [])) { const id = rec && _recId(def, rec); if (id == null || id === "") continue; lastSent.set(recKey(col, String(id)), JSON.stringify(rec)); } } } setConnState("online"); notify(); for (const d of dirtyMerged) announceConflict(d.col, d.id, d.conflicts, null); // Anything still dirty (offline edits merged above, or purely-local // records) flushes straight back up — the diff sees it as changed. persist(); return { ok: true, rev: boot.rev, merged: localOnly }; } catch (e) { // A 401 already flipped us to auth-expired inside apiFetch; anything // else (network down, 5xx) is plain offline. if (__connState !== "auth-expired") setConnState("offline"); return { ok: false, reason: e.message || "fetch-failed" }; } } // ── WebSocket live sync ───────────────────────────────────────────── let socket = null; let wsReconnectMs = 1000; let wsBackoffTimer = null; let wsRoute = "home"; // Merge a single fresh record back into state arrays / singletons. function mergeRecord(col, id, record) { const def = SERVER_COLLECTIONS[col]; if (!def) return; if (def.singleton) { state[col] = record || {}; lastSent.set(recKey(col, "__singleton__"), JSON.stringify(state[col])); return; } if (def.map) { if (!state[col] || typeof state[col] !== "object" || Array.isArray(state[col])) state[col] = _colToMap(state[col], def.idField); if (record === null || record === undefined) { delete state[col][String(id)]; lastSent.delete(recKey(col, id)); } else { state[col][String(id)] = record; lastSent.set(recKey(col, id), JSON.stringify(record)); } return; } state[col] = Array.isArray(state[col]) ? state[col] : []; const ix = state[col].findIndex(r => r && String(_recId(def, r)) === String(id)); if (record === null || record === undefined) { if (ix >= 0) state[col].splice(ix, 1); lastSent.delete(recKey(col, id)); } else { if (ix >= 0) state[col][ix] = record; else state[col].unshift(record); lastSent.set(recKey(col, id), JSON.stringify(record)); } } async function fetchOneRecord(col, id) { try { const r = await apiGet("/" + col + "?id=" + encodeURIComponent(id)); if (r && r.rev != null) lastRev.set(recKey(col, id), Number(r.rev)); return r && r.record !== undefined ? r.record : null; } catch (e) { if (e && e.status === 404) return null; // deleted return undefined; // network glitch — leave alone } } // Apply a record that just changed on the server. If THIS station has // unsaved edits to the same record, merge field-by-field and keep it dirty // (so our edits still go up) instead of overwriting them. function applyRemoteRecord(col, id, rec, by) { const def = SERVER_COLLECTIONS[col]; if (!def) return; const k = recKey(col, id); let local = null; if (def.singleton) local = state[col]; else if (def.map) local = state[col] && state[col][String(id)]; else local = (state[col] || []).find(r => r && String(_recId(def, r)) === String(id)); const sentJson = lastSent.get(k); // (sales features H6) another PC's NEW record arrived under the id of our own new, unsent // record: re-number ours instead of letting theirs overwrite it. if (rec && local && sentJson === undefined && dmsIsIdCollision(col, local, rec)) { dmsRekeyCollision(col, id, local, rec, null); persist(); return; } const dirty = local && sentJson !== undefined && sentJson !== JSON.stringify(local); if (!dirty || rec === null) { mergeRecord(col, id, rec); return; } if (DMS_FINANCIAL[col]) { // (ldg) A financial record is never merged field by field: another station saved it first, so the saved copy is shown, // this station's unsaved change is dropped and the person is told to make it again (the same answer the server's 409 gives). mergeRecord(col, id, rec); if (!(col === "checkConfig" && by === "server")) { // the server's own next-number mirror of the check setup is not a conflict const msg = `${col} ${id} was changed on another station${by ? " (" + by + ")" : ""} — the screen now shows the saved copy; make your change again.`; recordFinancialRefusal(col, id, 409, { code: "conflict", error: msg }); try { if (typeof window !== "undefined" && window.k2bToast) window.k2bToast("⚠ " + msg, "warn", 8000); } catch (e) {} } persist(); notify(); return; } const { merged, conflicts } = threeWayMerge(JSON.parse(sentJson), local, rec); announceConflict(col, id, conflicts, by); setStateRecord(col, id, merged); lastSent.set(k, JSON.stringify(rec)); // their copy is the new base; merged ≠ base → still dirty persist(); // push the merge back up (baseRev = their rev) } function scheduleReconnect() { if (wsBackoffTimer) return; wsBackoffTimer = setTimeout(() => { wsBackoffTimer = null; connectSocket(); }, wsReconnectMs); wsReconnectMs = Math.min(8000, wsReconnectMs * 2); } // H12 (sales features): the live connection signs in with a one-time ticket // (POST /api/ws-ticket) so the session token never travels in a URL. A // server without that route (404; 403 for a Sales login there) still gets // today's ?token= URL; any other error (network, 5xx) retries later — it // never falls back to the token on a passing error. // wsGen ignores a ticket that arrives after a newer connectSocket() call. let wsGen = 0; function connectSocket() { if (typeof WebSocket === "undefined" || !AUTH_TOKEN || !WS_URL) return; if (__offlineByChoice) return; // explicit offline session — no live link // Tear down any previous socket FIRST — after a fresh login the old // socket still holds the old (possibly expired) token, and its onclose // handler would otherwise null out the new socket reference. if (wsBackoffTimer) { clearTimeout(wsBackoffTimer); wsBackoffTimer = null; } if (socket) { const old = socket; socket = null; try { old.onclose = null; old.onerror = null; old.onmessage = null; old.close(); } catch (e) {} } const gen = ++wsGen; apiPost("/ws-ticket", {}).then( (r) => { if (gen !== wsGen || !AUTH_TOKEN || __offlineByChoice) return; // a newer call (or a sign-off) won if (!r || !r.ticket) { scheduleReconnect(); return; } openSocket(WS_URL + "?ticket=" + encodeURIComponent(r.ticket) + "&sid=" + encodeURIComponent(SESSION_ID)); }, (e) => { if (gen !== wsGen || !AUTH_TOKEN || __offlineByChoice) return; // RC35 (brd): the ?token= URL is gone (the server removed it, SPEC 3.9) - a refused ticket just tries again later if (e && e.body && (e.body.code === "store_denied" || e.body.code === "store_off")) return; // the refused-store window is up scheduleReconnect(); }); } function openSocket(url) { if (socket) { const old = socket; socket = null; try { old.onclose = null; old.onerror = null; old.onmessage = null; old.close(); } catch (e) {} } try { socket = new WebSocket(url); } catch (e) { scheduleReconnect(); return; } socket.onopen = () => { wsReconnectMs = 1000; setConnState("online"); // live socket up → definitely online try { socket.send(JSON.stringify({ type: "presence", route: wsRoute })); } catch (e) {} }; socket.onmessage = async (ev) => { let m = null; try { m = JSON.parse(ev.data); } catch (e) { return; } if (!m || !m.type) return; // RC35 (brd): a message for another store is never applied here; the store's branding / the store list changed if (m.store != null && window.K2BStore && window.K2BStore.pageId() && String(m.store) !== window.K2BStore.pageId()) return; if (m.type === "store-branding") { try { window.K2BStore && window.K2BStore._branding(m); } catch (e) {} return; } if (m.type === "stores-changed") { apiGet("/stores").then((a) => { try { window.K2BStore && window.K2BStore._answer(a); } catch (e) {} }, () => {}); return; } if (m.type === "write") { if (m.by && currentUser && m.by === currentUser.user && m.from === SESSION_ID) return; // own write echo const rec = await fetchOneRecord(m.collection, m.recordId); if (rec !== undefined) { applyRemoteRecord(m.collection, m.recordId, rec, m.by); notify(); fireRemote({ rev: m.rev, sessionId: m.from, user: m.by, action: "Remote write", ts: Date.now() }); } } else if (m.type === "delete") { mergeRecord(m.collection, m.recordId, null); notify(); fireRemote({ sessionId: m.from, user: m.by, action: "Remote delete", ts: Date.now() }); } else if (m.type === "presence") { presenceUsers = Array.isArray(m.users) ? m.users : []; firePresence(); } else if (m.type === "reset" || m.type === "import") { // bulk change — re-bootstrap; the server copy must win outright here // (a merge would resurrect records the reset/import just removed). loadFromServer({ replace: true }).then(() => notify()); } else { // RC27 (dsk): a we-owe changed / the lender fee defaults changed on another station — refresh the copy this station holds (no money in the message) try { if (m.type === "weOwe" && api._weOwes.at) api.loadWeOwes(api._weOwes.status || "open"); if (m.type === "lenderFees" && api._lenderFees.at) api.loadLenderFees(); } catch (e) {} try { if (m.type === "payPlan") api.loadPayPlan(); } catch (e) {} // RC28 (fx): a new pay plan version — every station reads the server's plan again try { window.dispatchEvent(new CustomEvent("k2b-ws", { detail: m })); } catch (e) {} } }; socket.onclose = () => { socket = null; scheduleReconnect(); }; socket.onerror = () => { try { socket && socket.close(); } catch (e) {} }; } function sendPresenceOverWs() { if (socket && socket.readyState === 1) { try { socket.send(JSON.stringify({ type: "presence", route: wsRoute })); } catch (e) {} } } // local-tab record locks survive (per-machine UI gesture, not server state) const readLocks = () => { try { return JSON.parse(localStorage.getItem(LOCK_KEY) || "{}"); } catch (e) { return {}; } }; const writeLocks = (m) => { try { localStorage.setItem(LOCK_KEY, JSON.stringify(m)); } catch (e) {} }; if (typeof window !== "undefined") { window.addEventListener("storage", (e) => { if (e.key === AUTH_TOKEN_KEY) { // Two people on two TABS of the same PC share localStorage. If another // tab signs in as a DIFFERENT user, this tab must NOT silently become // that user — it keeps its own session in memory and says so once. const incoming = decodeTokenInfo(e.newValue || ""); const mine = currentUser && currentUser.user ? String(currentUser.user) : ""; // RC35 (brd): another tab switched store - this tab keeps its own token (its store); StoreKit shows "This window is in " if (incoming && incoming.store && window.K2BStore && window.K2BStore.pageId() && incoming.store !== window.K2BStore.pageId()) return; if (mine && AUTH_TOKEN && incoming && incoming.sub && String(incoming.sub) !== mine) { try { window.k2bToast && window.k2bToast(`Another tab on this PC signed in as ${incoming.name || incoming.sub}. This tab stays signed in as ${mine}.`, "warn", 6000); } catch (e2) {} return; } if (mine && AUTH_TOKEN && !e.newValue) return; // another tab signed out — this tab's session stays AUTH_TOKEN = e.newValue || null; if (AUTH_TOKEN && !__offlineByChoice) connectSocket(); } else if (e.key === PRESENCE_KEY || e.key === LOCK_KEY) firePresence(); else if (e.key === dmsKey() && !AUTH_TOKEN && e.newValue && !isEnc(e.newValue) && !lockedVault) { // Another station/tab on this machine saved — adopt its state so every // open screen updates live (server-less multi-user). With a server // connected, the websocket handles this instead. try { state = parseRaw(e.newValue); notify(); } catch (err) {} } }); const cleanup = () => { try { socket && socket.close(); } catch (e) {} const lk = readLocks(); let changed = false; for (const k in lk) if (lk[k].sessionId === SESSION_ID) { delete lk[k]; changed = true; } if (changed) { try { localStorage.setItem(LOCK_KEY, JSON.stringify(lk)); } catch (e) {} } }; window.addEventListener("beforeunload", cleanup); window.addEventListener("pagehide", cleanup); // Kick the socket on boot if we already have a token (page refresh). if (AUTH_TOKEN) { const ti = decodeTokenInfo(); if (ti && ti.expired) { // The stored token is ALREADY expired — don't even try. Clear it and // announce auth-expired so the sign-on screen says so out loud. setAuthToken(null); setConnState("auth-expired"); } else { // bootstrap state from the server; failure flips the visible // connection state (offline / auth-expired) — never silent. // RC35 (brd, SPEC 4.1 step 2): a sign-in kept from before RC35 (no store claim) is moved into its store first (one reload) (ti && !ti.store ? dmsStoreUpgrade() : Promise.resolve("ok")).then((u) => { if (u === "reload") return; loadFromServer().then((r) => { if (r && r.ok) refreshRoster(); }) .finally(() => { connectSocket(); notify(); }); }); } } else { __connState = "offline"; // no token yet — plain local mode until sign-on } } // GL helper — write to the shared journal-entries collection (which syncs to // the server and broadcasts to other stations) AND route through the // Accounting JEStore so the GLINQ screen re-renders in this same session. const postGL = (entry) => { if (!state.journalEntries) state.journalEntries = []; // ── Accounting period / POST-AHEAD ── // Every entry is stamped with its period (YYYY-MM). If the dealership is // already working in a new month but hasn't closed the prior one, the // entry posts normally and is flagged postAhead — it "belongs" to the new // month and is released when the prior month closes. Classic DMS post-ahead. const per = _periodOf(entry.date); const ap = _acctPeriod(); entry = { ...entry, period: per }; if (per > ap.open) entry.postAhead = true; state.journalEntries.unshift({ ...entry }); if (typeof window !== "undefined" && window.__k2bJEStore) { // posted:true → store it permanently, not as a draft. window.__k2bJEStore.add(entry, { posted: true }); } // Every GL-affecting action also writes to the audit trail. logAudit(entry.src || "GL", `${entry.id} — ${entry.desc || ""}${entry.postAhead ? " [POST-AHEAD " + per + "]" : ""}`); // A posting is money on the books — push it to the server IMMEDIATELY // (microtask: runs after the calling method's persist() has queued the // dirty records, skipping the normal 250ms debounce). Promise.resolve().then(() => { try { if (!AUTH_TOKEN) return; if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } flushNow(); } catch (e) {} }); }; // "YYYY-MM" for an entry date like 07/01/26 or an ISO string. const _periodOf = (d) => { const dt = d ? new Date(d) : new Date(); if (isNaN(dt)) return new Date().toISOString().slice(0, 7); return dt.getFullYear() + "-" + String(dt.getMonth() + 1).padStart(2, "0"); }; // Lazy-init the open accounting period: the month of the most recent posted // journal entry (where the books "are"), else the current month. const _acctPeriod = () => { if (!state.acctPeriod || !state.acctPeriod.open) { const jes = state.journalEntries || []; const last = jes.length ? jes.map(j => _periodOf(j.date)).sort().pop() : null; state.acctPeriod = { open: last || _periodOf(), closed: [] }; } return state.acctPeriod; }; // ── Roles (C-01) ────────────────────────────────────────────────────── // Canonical role strings match the server's TEAM_ROLES exactly. An empty / // unknown role is LEAST privilege: it is never admin, and gating treats it // like a read-only Sales session until a real role arrives from the server. const ROLE_CANON = ["Admin / F&I", "Office Manager", "Sales Manager", "Service Advisor", "Sales", "Accounting", "Service", "Parts", "Technician"]; // What each role may DO (screens are fenced in app.jsx from the same map). // sales CRM + customers desk / fi / funding deal workflow // dealAdj Edit Deal Backend inventory units, stock-in, recon // service ROs, appointments, parts glView / glEdit read vs change the GL // period close / reopen / set month acct AP, AR, payroll, checks // admin users, settings, controls data import / export const ROLE_CAPS = { "Admin / F&I": "*", "Office Manager": ["sales", "desk", "fi", "funding", "dealAdj", "inventory", "service", "glView", "glEdit", "period", "acct", "reports", "data", "integrations"], "Sales Manager": ["sales", "desk", "fi", "funding", "dealAdj", "inventory", "serviceView", "glView", "reports", "integrations"], "Service Advisor": ["sales", "service", "inventoryView", "integrations"], "Sales": ["sales", "inventoryView", "roView"], // see units + when their customers are in service (read-only) "Accounting": ["sales", "desk", "fi", "funding", "dealAdj", "inventory", "service", "glView", "glEdit", "period", "acct", "reports", "data", "integrations"], // legacy department logins: their own area only (owner blocker 2 / lead decision 2), the mirror of the server's permission // matrix (src/lib/rolematrix.js) which refuses them every financial collection anyway. The menus must not open screens whose // first save is refused: a Parts station opening Payroll auto-added 21 pay records -> 21 refused writes (round-2 regress finding). "Service": ["service", "inventoryView", "integrations"], "Parts": ["service", "inventoryView"], // RC28 (svc) fix (svc-aud finding 2): the Technician login had no page role at all (only the CRM workspace). It now opens the Service // Department and the repair orders READ-ONLY (like Sales' roView — the server matrix lets a technician read them) and, through the // service pack, Service Dept › Maintenance schedules and the service menu (DMS.svcCanPresent — the server decides every request). "Technician": ["roView"], }; const normRole = (r) => { const s = String(r || "").trim(); if (!s) return ""; const hit = ROLE_CANON.find(c => c.toLowerCase() === s.toLowerCase()); if (hit) return hit; // Legacy spellings of the admin role ("Admin", "Admin/F&I", "Administrator"). if (/^admin(istrator)?(\s*\/\s*f\s*&\s*i)?$/i.test(s)) return "Admin / F&I"; if (/^(office\s*(manager|mgr)|controller)$/i.test(s)) return "Office Manager"; if (/^((sales|general|gm)\s*)?(manager|mgr)$|^f\s*&\s*i(\s*manager)?$/i.test(s)) return "Sales Manager"; if (/^service(\s*(advisor|writer|manager))$/i.test(s)) return "Service Advisor"; if (/^(service\s*)?tech(nician)?$/i.test(s)) return "Technician"; // RC28 (svc) fix: the server's spelling rule (lib/rolematrix.js) return ""; // unknown → least privilege (never a partial /sales/ match) }; // Session identity. NOT defaulted to an admin: until SignOn / the JWT hands // us a real role, this session has no role (least privilege). let currentUser = { user: "", role: "" }; const currentRole = () => { const r = normRole(currentUser && currentUser.role); if (r) return r; const ti = decodeTokenInfo(); return ti && !ti.expired ? normRole(ti.role) : ""; }; // Who may write the server audit collection: any real non-Sales role. // Sales (and role-less sessions) keep audit LOCAL — POSTing would 403 and // leave a poison record in the sync queue. const auditSyncAllowed = () => { const r = currentRole(); return !!r && r !== "Sales"; }; // Audit log — who/what/when. currentUser is set by the app shell at sign-on. const logAudit = (action, detail) => { state.audit = state.audit || []; const id = "A-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 6); state.audit.unshift({ id, ts: new Date().toISOString(), user: currentUser.user, role: currentRole(), action, detail: detail || "", }); if (state.audit.length > 500) state.audit.length = 500; // ring buffer // Also fire a server-side audit row (best-effort) — only for roles the // server lets write /audit. Sales / role-less sessions queue nothing. if (AUTH_TOKEN && !__offlineByChoice && auditSyncAllowed()) { apiPost("/audit", { action, detail: detail || "" }).catch(() => {}); } }; // ── Check setup helpers ────────────────────────────────────────── // ABA routing check digit: 3·(d1+d4+d7) + 7·(d2+d5+d8) + (d3+d6+d9) ≡ 0 (mod 10) const _abaOk = (r) => { if (!/^\d{9}$/.test(r)) return false; const d = r.split("").map(Number); return (3 * (d[0] + d[3] + d[6]) + 7 * (d[1] + d[4] + d[7]) + (d[2] + d[5] + d[8])) % 10 === 0 && r !== "000000000"; }; // Normalized check config (migrates the old single-account shape). const _checkCfg = () => { const raw = state.checkConfig || {}; const cfg = JSON.parse(JSON.stringify(raw)); if (!Array.isArray(cfg.accounts) || !cfg.accounts.length) { cfg.accounts = [{ id: "BK1", label: raw.bankName || "Operating", glAcct: raw.bankAcct || "202", bankName: "", bankLine2: "", fractional: "", routing: "", account: "", nextNum: Math.max(1, parseInt(raw.nextNum, 10) || 1), stock: "top", micrFormat: "business", sigLine: true, sigLines: 1, sigOver: "", offsetX: 0, offsetY: 0, micrOffsetY: 0, voidAfterDays: 90 }]; cfg.defaultId = "BK1"; } if (!cfg.defaultId || !cfg.accounts.some(a => a.id === cfg.defaultId)) cfg.defaultId = cfg.accounts[0].id; cfg.company = cfg.company || {}; const d = cfg.accounts.find(a => a.id === cfg.defaultId); // legacy mirror fields for older screens cfg.nextNum = d.nextNum; cfg.bankAcct = d.glAcct; cfg.bankName = d.label; cfg.label = d.label; return cfg; }; const _checkCfgStore = (cfg) => { const out = { accounts: cfg.accounts, defaultId: cfg.defaultId, company: cfg.company || {} }; if (cfg.micrFont) { out.micrFont = cfg.micrFont; out.micrFontName = cfg.micrFontName || "uploaded"; } return out; }; // RC32: Setup's made-up "Preview an RO" RO — readable by look-only reads while the preview is open; never in the store const _svcPreviewROs = new Map(); // RC33 (mpx): the money jobs are server commands (MoneyCore.jsx.txt replaces these stubs) — a page without MoneyCore posts nothing // RC33 fix round: what is still OPEN on a warranty claim (claimed − already received − charged back while unpaid) — what 263 carries const wOpenOf = (w) => Math.max(0, Math.round((+w.amount || 0) * 100) - Math.round((+w.received || 0) * 100) - Math.round((+w.debitedOpen || 0) * 100)) / 100; const moneyMissing = (what) => Promise.resolve({ ok: false, msg: what + " is done by the server — this DMS page is missing MoneyCore.jsx.txt. Reload the DMS (Ctrl+F5)." }); const api = { // ── raw access ── get: () => state, inventory: () => state.inventory, deals: () => state.deals, repairOrders: () => state.repairOrders, parts: () => state.parts, partsOrders: () => state.partsOrders || [], cores: () => state.cores || [], technicians: () => state.technicians || [], appointments: () => state.appointments || [], payables: () => state.payables || [], bodyShopROs: () => state.bodyShopROs || [], checks: () => state.checks || [], vendors: () => state.vendors || [], // ── Employees (payroll) & service technicians ──────────────── employees() { if (!state.employees) state.employees = (state.meta && state.meta.blank) ? [] : JSON.parse(JSON.stringify((typeof window !== "undefined" && window.__PR_EMPLOYEES_DEFAULT) || [])); return state.employees; }, setEmployees(list) { state.employees = Array.isArray(list) ? list : []; persist(); notify(); return { ok: true, msg: "Employees saved." }; }, // Pull every SERVER user (team roster) into payroll — adding a login // automatically creates the payroll employee. New records arrive flagged // needsSetup (rate 0) until pay is configured. The LOCAL user registry // (window.jbUsers — shipped demo accounts) is deliberately NOT a source: // it seeded demo employees onto the server on first sign-in (P1-6). syncUsersToPayroll() { const list = api.employees(); const norm = (s) => String(s || "").trim().toUpperCase().replace(/[^A-Z ,]/g, ""); const flip = (n) => n.includes(",") ? n.split(",").map(x => x.trim()).filter(Boolean).reverse().join(" ") : n.split(/\s+/).length === 2 ? n.split(/\s+/).reverse().join(", ") : ""; const have = new Set(); list.forEach(e => { const n = norm(e.name); have.add(n); const f = flip(n); if (f) have.add(norm(f)); }); const src = __teamRoster.map(u => ({ name: u.name || u.id, role: u.role })); const deptOf = (r) => /sales|desk|f&i|finance/i.test(r || "") ? "SAL" : /service|tech|advisor/i.test(r || "") ? "SVC" : /part/i.test(r || "") ? "PTS" : "OFF"; let added = 0; src.forEach(u => { const n = norm(u.name); if (!n || have.has(n) || (flip(n) && have.has(norm(flip(n))))) return; have.add(n); const f = flip(n); if (f) have.add(norm(f)); list.push({ id: "E-" + String(Date.now()).slice(-5) + added, name: u.name, dept: deptOf(u.role), type: "Hourly", rate: 0, hrs: 0, ot: 0, comm: 0, deductions: [], needsSetup: true }); added++; }); if (added) { logAudit("Payroll Staff Sync", added + " user(s) added to payroll"); persist(); notify(); } return { ok: true, added, msg: added ? added + " user(s) added to payroll from Users — set their pay in Edit Payroll." : "Payroll already matches the user list." }; }, upsertEmployee(rec) { const list = api.employees(); const e = { deductions: [], ...rec }; e.id = e.id || ("E-" + String(100 + list.length + 1)); e.name = (e.name || "").trim(); if (!e.name) return { ok: false, msg: "Employee name is required." }; const i = list.findIndex(x => x.id === e.id); if (i >= 0) list[i] = { ...list[i], ...e }; else list.unshift(e); logAudit("Employee " + (i >= 0 ? "Updated" : "Added"), `${e.id} — ${e.name}`); persist(); notify(); return { ok: true, msg: `${e.name} saved.` }; }, removeEmployee(id) { state.employees = api.employees().filter(x => x.id !== id); logAudit("Employee Removed", id); persist(); notify(); return { ok: true, msg: "Employee removed." }; }, upsertTechnician(rec) { state.technicians = state.technicians || []; const t = { availToday: 8.0, flaggedToday: 0, flaggedMTD: 0, clockMTD: 0, ...rec }; t.id = (t.id || ("TECH" + String(state.technicians.length + 1).padStart(2, "0"))).toUpperCase(); t.name = (t.name || "").trim(); if (!t.name) return { ok: false, msg: "Technician name is required." }; t.rate = +t.rate || 0; const i = state.technicians.findIndex(x => x.id === t.id); if (i >= 0) state.technicians[i] = { ...state.technicians[i], ...t }; else state.technicians.unshift(t); logAudit("Technician " + (i >= 0 ? "Updated" : "Added"), `${t.id} — ${t.name}`); persist(); notify(); return { ok: true, msg: `${t.name} saved.` }; }, // RC32 (T1): link a technician to a sign-in ("Mine" / My jobs) — the office; the server decides linkTechnician(techId, userId) { if (!(api.isAdmin && api.isAdmin()) && !(api.can && api.can("glEdit"))) return { ok: false, denied: true, msg: "Only the office (Admin / F&I, Office Manager, Accounting) links a technician to a sign-in." }; const t = (state.technicians || []).find(x => x.id === techId); if (!t) return { ok: false, msg: "Technician not found." }; const u = String(userId == null ? "" : userId).trim(); const was = t.userId ? String(t.userId) : ""; if (u === was) return { ok: true, noop: true, msg: "No change." }; if (u) { const other = (state.technicians || []).find(x => x.id !== techId && String(x.userId || "") === u); if (other) return { ok: false, msg: `That sign-in is already linked to ${other.name || other.id}. Unlink it there first.` }; } if (u) t.userId = u; else delete t.userId; logAudit("Technician Sign-in", `${t.id} — ${t.name}: ${was || "(none)"} → ${u || "(none)"}`); persist(); notify(); return { ok: true, msg: u ? `${t.name} is linked to sign-in ${u}.` : `${t.name} is no longer linked to a sign-in.` }; }, // RC32 (T1): the technician linked to a sign-in (default: the one signed in) or null techForUser(userId) { const u = String(userId == null ? (currentUser && currentUser.user) || "" : userId).trim(); return u ? ((state.technicians || []).find(x => String(x.userId || "") === u) || null) : null; }, removeTechnician(id) { state.technicians = (state.technicians || []).filter(x => x.id !== id); logAudit("Technician Removed", id); persist(); notify(); return { ok: true, msg: "Technician removed." }; }, bulkFluids() { if (!state.bulkFluids) state.bulkFluids = ((state.meta && state.meta.blank) || !(window.K2BStore && window.K2BStore.isPrimary())) ? [] : JSON.parse(JSON.stringify(BULK_FLUIDS_DEFAULT)); // RC35: the sample tanks are the primary store's only return state.bulkFluids; }, // ── Users mirror — REMOVED (C-02c) ───────────────────────────── // The local Users & Logins registry (window.jbUsers, plaintext passwords) // is NEVER mirrored into DMS state or the server. Server accounts are // managed only via /api/team (teamList / teamCreate / teamUpdate / // teamSetPassword). Kept as an inert no-op so the Registry bridge (which // still calls it) cannot throw; it writes nothing. syncUsersFromRegistry() { return { ok: true, unchanged: true, disabled: true }; }, checkConfig: () => _checkCfg(), bankRec: () => state.bankRec || {}, audit: () => state.audit || [], arCustomers: () => state.arCustomers || [], arDefaults: () => state.arDefaults || {}, arAccountTypes: () => state.arAccountTypes || [], arTaxCodes: () => state.arTaxCodes || [], arDunningMessages: () => state.arDunningMessages || [], arLetterTemplates: () => state.arLetterTemplates || [], arLetters: () => state.arLetters || [], // (sales features H8) a COPY, newest first by `created` (else `valuedAt`): Desking / Buyer's // Order "Load Appraisal" take [0], which must be the newest after a reload too. A trade the // store declined or the salesperson withdrew (source "trade", status no longer "Valued") is // left out, so "Load Appraisal" and the CRM picker never offer a car the store did not take. appraisals: () => (state.appraisals || []).filter((a) => !(a && a.source === "trade" && a.status && a.status !== "Valued")).sort((a, b) => { const t = (x) => { const v = Date.parse((x && (x.created || x.valuedAt)) || ""); return isNaN(v) ? 0 : v; }; return t(b) - t(a); }), // (sales features H9) trade photo bytes -> Blob (show only inside an ) apiBlob: (path) => dmsApiBlob(path), // (sales features H10) the service types an appointment can carry (same list as the server's alerts) SVC_APPT_TYPES: ["Oil change", "Maintenance", "Tires & alignment", "Brakes", "Battery", "Inspection", "Diagnosis", "Warranty / recall", "Repair", "Other"], // RC32 (L10): the store's appointment types (today's ten when not set) svcApptTypes() { try { const l = api.svcSettings ? api.svcSettings().ro.lists.apptTypes : null; if (Array.isArray(l) && l.length) return l.slice(); } catch (e) {} return api.SVC_APPT_TYPES.slice(); }, // CRM leads — shared store so every station sees new leads in real time // (server-synced on the deployed build; cross-tab locally). crmLeads: () => state.crmLeads || [], crmSetLeads(list) { state.crmLeads = Array.isArray(list) ? list : []; persist(); notify(); }, // Saved desking scenarios — shared store so a worked deal can be reopened // from any workstation (server-synced on the deployed build; cross-tab locally). deskScenarios: () => state.deskScenarios || [], saveDeskScenario(rec) { if (!rec || !rec.id) return { ok: false, msg: "id is required." }; if (!state.deskScenarios) state.deskScenarios = []; const r = { ...rec, savedBy: (currentUser && currentUser.user) || "", savedAt: new Date().toISOString() }; const ix = state.deskScenarios.findIndex(s => s && String(s.id) === String(r.id)); if (ix >= 0) state.deskScenarios[ix] = r; else state.deskScenarios.unshift(r); persist(); notify(); return { ok: true, id: r.id }; }, deleteDeskScenario(id) { if (!state.deskScenarios) return { ok: false }; const ix = state.deskScenarios.findIndex(s => s && String(s.id) === String(id)); if (ix >= 0) state.deskScenarios.splice(ix, 1); persist(); notify(); return { ok: true }; }, // Session identity / role helpers (C-01). Roles are exact canonical // strings ("Admin / F&I" | "Sales" | "Accounting" | "Service" | "Parts"); // an empty/unknown role is least privilege — never admin, and gated like // a read-only Sales session. Callers (app.jsx ROLE_NAV etc.) should use // DMS.roleIs("Sales") / DMS.isAdmin() / DMS.isRestricted() instead of // regex tests on the raw string ("Sales Manager" would match /sales/i). setUser(u) { if (u) currentUser = { user: String(u.user || ""), role: normRole(u.role) }; }, userId: () => (currentUser && currentUser.user) || "", userRole: () => currentRole(), normRole, ROLES: ROLE_CANON.slice(), roleIs: (name) => { const want = normRole(name); return !!want && currentRole() === want; }, isAdmin: () => currentRole() === "Admin / F&I", ROLE_CAPS: ROLE_CAPS, // can("glEdit") etc. — Admin can do everything; no role = nothing. // RC22 (own): + the owner's per-user grants / revocations (dmsOwnGrantCan); "dealPost" = posting / funding / payments on deals (the office, i.e. the roles with glEdit) can: (cap) => { const role = currentRole(); const c = ROLE_CAPS[role]; const k = cap === "dealPost" ? "glEdit" : cap; const base = c === "*" || (Array.isArray(c) && (c.includes(k) || (k === "inventoryView" && c.includes("inventory")) || (k === "serviceView" && c.includes("service")) || (k === "roView" && (c.includes("service") || c.includes("serviceView"))))); return dmsOwnGrantCan(cap, base, role); }, hasRole: () => !!currentRole(), // true for Sales AND for a session with no real role yet (least privilege). isRestricted: () => { const r = currentRole(); return !r || r === "Sales"; }, auditSyncAllowed, logAudit, warrantyReceivables: () => state.warrantyReceivables, factoryReceivables: () => state.factoryReceivables, commissions: () => state.commissions, floorplanPayoffs: () => state.floorplanPayoffsDue, lenderSubmissions: () => state.lenderSubmissions || [], // ── GL account-flow map (editable in Settings → GL Flow) ────────── getAcctMap() { return Object.keys(ACCT_META).map(key => ({ key, acct: ACCT[key], def: ACCT_DEFAULTS[key], group: ACCT_META[key].group, label: ACCT_META[key].label, changed: ACCT[key] !== ACCT_DEFAULTS[key], })); }, setAcctMap(overrides) { if (!overrides || typeof overrides !== "object") return { ok: false, msg: "No changes." }; let n = 0; Object.keys(overrides).forEach(k => { if (k in ACCT) { const v = String(overrides[k] || "").trim(); if (v && v !== ACCT[k]) { ACCT[k] = v; n++; } } }); // persist only the diff from defaults const diff = {}; Object.keys(ACCT).forEach(k => { if (ACCT[k] !== ACCT_DEFAULTS[k]) diff[k] = ACCT[k]; }); try { localStorage.setItem(ACCT_OVERRIDE_KEY, JSON.stringify(diff)); } catch (e) {} logAudit("GL Account Map Changed", `${n} account(s) remapped`); notify(); return { ok: true, changed: n, msg: `${n} account mapping${n !== 1 ? "s" : ""} saved — future postings use the new accounts.` }; }, resetAcctMap() { Object.keys(ACCT_DEFAULTS).forEach(k => { ACCT[k] = ACCT_DEFAULTS[k]; }); try { localStorage.removeItem(ACCT_OVERRIDE_KEY); } catch (e) {} logAudit("GL Account Map Reset", "Restored standard GM chart mapping"); notify(); return { ok: true, msg: "GL account map reset to the standard GM chart." }; }, // Full posting map — every department's transactions, account #s resolved getPostingMap() { const resolve = (l) => { const acct = l.key ? (ACCT[l.key] || "?") : (l.lit || ""); return { side: l.side, acct, key: l.key || null, lit: l.lit || null, editable: !!l.key, memo: l.memo }; }; return POSTING_MAP.map(d => ({ dept: d.dept, txns: d.txns.map(t => ({ doc: t.doc, jrn: t.jrn, prefix: t.prefix, trigger: t.trigger, lines: t.lines.map(resolve), })), })); }, glAccounts: () => state.glAccounts || [], journalEntries: () => state.journalEntries || [], taxDeposits: () => state.taxDeposits || [], // ── Payroll tax deposits (the "pay taxes" flow) ── // Federal 941 deposit (FIT + SS + Medicare) or state withholding deposit. // Posts DR payroll-tax liability / CR operating cash and logs the deposit. // RC33 (mpx): a SERVER command now — MoneyCore.jsx.txt (loaded after this file) replaces it; this stub only answers when that file is missing payTaxDeposit(o) { return moneyMissing("A tax deposit"); }, // ── Accounting / GL chart + journal-entry CRUD ── upsertGlAccount(rec) { if (!rec || !rec.acct) return { ok: false, msg: "acct is required." }; if (!state.glAccounts) state.glAccounts = []; const ix = state.glAccounts.findIndex(a => a && String(a.acct) === String(rec.acct)); if (ix >= 0) state.glAccounts[ix] = { ...rec }; else state.glAccounts.push({ ...rec }); persist(); notify(); return { ok: true }; }, deleteGlAccount(acct) { if (!state.glAccounts) return { ok: false }; const ix = state.glAccounts.findIndex(a => a && String(a.acct) === String(acct)); if (ix >= 0) state.glAccounts.splice(ix, 1); persist(); notify(); return { ok: true }; }, upsertJournalEntry(rec) { if (!rec || !rec.id) return { ok: false, msg: "id is required." }; if (!state.journalEntries) state.journalEntries = []; const ix = state.journalEntries.findIndex(j => j && String(j.id) === String(rec.id)); if (ix >= 0) state.journalEntries[ix] = { ...rec }; else state.journalEntries.unshift({ ...rec }); persist(); notify(); return { ok: true }; }, deleteJournalEntry(id) { if (!state.journalEntries) return { ok: false }; const ix = state.journalEntries.findIndex(j => j && String(j.id) === String(id)); if (ix >= 0) state.journalEntries.splice(ix, 1); persist(); notify(); return { ok: true }; }, resetGlAccounts(arr) { state.glAccounts = Array.isArray(arr) ? arr.slice() : []; persist(); notify(); return { ok: true }; }, subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }, // ── multi-user live layer (presence, sync, locks) ── session: () => ({ sessionId: SESSION_ID, station: STATION }), onRemote(fn) { remoteListeners.add(fn); return () => remoteListeners.delete(fn); }, onPresence(fn) { presenceListeners.add(fn); return () => presenceListeners.delete(fn); }, presence: () => (presenceUsers || []).map(u => ({ user: u.user, role: u.role, route: u.route || "home", sessionId: u.sessionId, station: "Station " + (10 + (((u.sessionId || "").split("").reduce((h,c)=>(h*31+c.charCodeAt(0))>>>0,0)) % 89)), ts: Date.now(), })), setPresence(p) { wsRoute = (p && p.route) || "home"; sendPresenceOverWs(); firePresence(); }, updateRoute(route) { wsRoute = route || "home"; sendPresenceOverWs(); firePresence(); }, clearPresence() { try { socket && socket.close(); } catch (e) {} firePresence(); }, // RC35 (brd): the store switch (StoreKit.jsx.txt) - writes in flight, what is not on the server yet, close the live link // (one object, not new DMS functions: the page's function list stays RC34's) storeSwitch: { inflight: () => __k2bInflight, pendingWork() { try { const d = diffAgainstLastSent(); return { changed: d.changed.length, deletes: d.deletes.length, flushing: !!(flushing || pendingReFlush) }; } catch (e) { return { changed: 1, deletes: 0, flushing: true }; } }, async flushPending(ms) { const deadline = Date.now() + (ms || 5000); const pause = (t) => new Promise(r => setTimeout(r, t)); try { for (let i = 0; i < 3 && Date.now() < deadline; i++) { while ((flushing || pendingReFlush) && Date.now() < deadline) await pause(50); const d = diffAgainstLastSent(); if (!d.changed.length && !d.deletes.length) break; await Promise.race([flushNow(), pause(Math.max(0, deadline - Date.now()))]); if (__connState === "offline" || __connState === "auth-expired") break; } while ((flushing || pendingReFlush) && Date.now() < deadline) await pause(50); } catch (e) {} return api.storeSwitch.pendingWork(); }, closeLive() { wsGen++; if (wsBackoffTimer) { clearTimeout(wsBackoffTimer); wsBackoffTimer = null; } if (socket) { const old = socket; socket = null; try { old.onclose = null; old.onerror = null; old.close(); } catch (e) {} } }, }, acquireLock(key) { const m = readLocks(), now = Date.now(), cur = m[key]; if (cur && cur.sessionId !== SESSION_ID && now - cur.ts < LOCK_TTL) return { ok: false, owner: cur }; m[key] = { sessionId: SESSION_ID, user: currentUser.user, station: STATION, ts: now }; writeLocks(m); firePresence(); return { ok: true }; }, releaseLock(key) { const m = readLocks(); if (m[key] && m[key].sessionId === SESSION_ID) { delete m[key]; writeLocks(m); firePresence(); } }, lockOwner(key) { const m = readLocks(), cur = m[key], now = Date.now(); return (cur && cur.sessionId !== SESSION_ID && now - cur.ts < LOCK_TTL) ? cur : null; }, manualSave(label) { logAudit("Manual Save", label || ""); persist(); notify(); return { ok: true }; }, // ── server connection ── isOnline: () => __isOnline, connState: () => __connState, // Per-record permanent sync failures this session (poison records that // were dropped from the dirty queue). [{collection,id,status,error,at}] syncIssues: () => __syncIssues.slice(), clearSyncIssues() { __syncIssues = []; notify(); }, // (ledger lane / ldg) Financial writes the server refused this session (reason shown, nothing kept) and the page's protocol number. financialRefusals: () => __financialRefusals.slice(), clearFinancialRefusals() { __financialRefusals = []; notify(); }, PROTOCOL: DMS_PROTOCOL, // Cached server team roster [{id,name,role}] — active users only, any role // may read it. Refreshed on boot + the 5-min safety timer (refreshRoster). roster: () => __teamRoster.slice(), onConn(fn) { connListeners.add(fn); return () => connListeners.delete(fn); }, tokenInfo: (tok) => decodeTokenInfo(tok), setAuthToken, getAuthToken: () => AUTH_TOKEN, // ── "Work Offline Anyway" (C-03b) ── // SignOn calls DMS.setOfflineByChoice(true) when the user explicitly picks // a local session. While set: no flush/pushAll/socket/auto-reconnect can // run under a leftover token (the token is dropped), no /audit posts. // Cleared by signOut and by a REAL sign-in (login / loginMfa / boot(token)). setOfflineByChoice(on) { __offlineByChoice = !!on; if (__offlineByChoice) { if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } if (wsBackoffTimer) { clearTimeout(wsBackoffTimer); wsBackoffTimer = null; } if (socket) { const old = socket; socket = null; try { old.onclose = null; old.onerror = null; old.close(); } catch (e) {} } setAuthToken(null); // a stale token must not resurrect a server identity offlineCacheWrite(); setConnState("offline", { quiet: true }); // deliberate — no failure alarm notify(); } return { ok: true, offlineByChoice: __offlineByChoice }; }, offlineByChoice: () => __offlineByChoice, async boot(token) { if (token) { __offlineByChoice = false; setAuthToken(token); } const ti = decodeTokenInfo(); if (ti && ti.expired) { // Stored token already dead — treat as auth-expired immediately. setAuthToken(null); setConnState("auth-expired"); notify(); return { ok: false, reason: "auth-expired" }; } if (!__offlineByChoice) { if ((await dmsStoreUpgrade()) === "reload") return { ok: false, reason: "store-reload" }; if (window.K2BStore && window.K2BStore._adopt() && dmsStoreReload("adopt")) return { ok: false, reason: "store-reload" }; // another store's copy is loaded here } const r = await loadFromServer(); if (r && r.ok) refreshRoster(); // fire-and-forget; failure keeps prior roster connectSocket(); notify(); return r; }, async signOut() { // H11 (d): on a server session, send what is still waiting (at most 5 s), // then wipe this PC's saved copy so the next person who signs in here // never sees — or pushes — this person's data. If something could not be // sent, the copy is kept and the person is told to come back. const serverSession = !!AUTH_TOKEN && !__offlineByChoice; const whoName = (() => { const ti = decodeTokenInfo(); return (ti && (ti.name || ti.sub)) || (currentUser && currentUser.user) || ""; })(); let pending = false; if (serverSession) { if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } const deadline = Date.now() + 5000; const pause = (ms) => new Promise(r => setTimeout(r, ms)); const dirty = () => { try { const d = diffAgainstLastSent(); return d.changed.length > 0 || d.deletes.length > 0; } catch (e) { return true; } }; try { for (let attempt = 0; attempt < 3 && Date.now() < deadline; attempt++) { while ((flushing || pendingReFlush) && Date.now() < deadline) await pause(50); if (flushing || !dirty()) break; await Promise.race([flushNow(), pause(Math.max(0, deadline - Date.now()))]); if (__connState === "offline" || __connState === "auth-expired") break; // the server is not taking saves now } while ((flushing || pendingReFlush) && Date.now() < deadline) await pause(50); } catch (e) {} if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } pending = dirty(); } wsGen++; // H12: a ticket still in flight opens nothing if (wsBackoffTimer) { clearTimeout(wsBackoffTimer); wsBackoffTimer = null; } if (socket) { const old = socket; socket = null; try { old.onclose = null; old.onerror = null; old.close(); } catch (e) {} } setAuthToken(null); __offlineByChoice = false; // next sign-on starts clean currentUser = { user: "", role: "" }; // no lingering role/identity clearLocalWork(); // (blocker 4) drafts / pointers never outlive the session if (serverSession) { if (!pending) { try { localStorage.removeItem(dmsKey()); localStorage.removeItem(dmsWhoKey()); } catch (e) {} state = _blank(); lastSent.clear(); lastRev.clear(); notify(); } else if (typeof window !== "undefined" && window.k2bToast) { if (vaultCfgEnabled() && !vaultOpen()) // the copy on disk is locked: the unsent changes could not be kept here window.k2bToast("Some changes did not reach the server, and this PC's saved copy is locked, so they could not be kept here. Check your last changes when the server is back.", "warn", 9000); else window.k2bToast(`Some changes on this PC have not reached the server yet. Sign in here again as ${whoName || "the same person"} before anyone else does, or they will be lost.`, "warn", 9000); } } // Deliberate sign-out is not a failed save — no alarm toast. setConnState("offline", { quiet: true }); }, // ── force full sync (data-recovery path) ── // Marks EVERY record in every server collection dirty and pushes them // sequentially, counting per-record failures instead of aborting, then // re-bootstraps. Use from Settings → Data & Backup after working offline. async pushAll() { if (__offlineByChoice) return { ok: false, pushed: 0, errors: ["Working offline by choice — sign in to the server first, then sync."] }; if (!AUTH_TOKEN) return { ok: false, pushed: 0, errors: ["Not signed in to the server — sign in first, then sync."] }; const ti = decodeTokenInfo(); if (ti && ti.expired) { setAuthToken(null); setConnState("auth-expired"); return { ok: false, pushed: 0, errors: ["Your session has expired — sign off, sign back in, then sync."] }; } if (lockedVault) return { ok: false, pushed: 0, errors: ["Workstation data is encrypted and locked — unlock it first."] }; // Don't race the debounced flush: cancel a pending run and wait out an // in-flight one, then take over the same machinery. if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } for (let i = 0; i < 200 && flushing; i++) await new Promise(r => setTimeout(r, 50)); // Forget every last-sent snapshot → the standard diff now reports // EVERY record as changed. lastSent.clear(); const { changed } = diffAgainstLastSent(); let pushed = 0; const errors = []; for (const ch of changed) { if (!AUTH_TOKEN) { // 401 mid-push cleared the token (apiFetch) — stop cleanly errors.push("Stopped: session lost mid-sync — " + (changed.length - pushed - (errors.length)) + " record(s) not attempted."); break; } try { if (SERVER_COLLECTIONS[ch.col] && SERVER_COLLECTIONS[ch.col].singleton) { await apiPost("/" + ch.col, ch.record); } else { await apiPost("/" + ch.col, { record: ch.record }); } lastSent.set(recKey(ch.col, ch.id), JSON.stringify(ch.record)); pushed++; } catch (e) { // Per-record failure: count it, keep going. (A 401 clears the // token via apiFetch, which the check above catches next loop.) errors.push(ch.col + "/" + ch.id + ": " + (e.message || "failed")); const st = e && e.status; if (e && e.body && isFinalRefusal(ch.col, e.body)) { dmsLedgerRefused(ch.col, ch.id, e.body, st); // (ledger lane) the server refused a financial record: its copy comes back, the reason is shown } else if (isPermanentStatus(st)) { // Permanent rejection — drop it from the dirty queue exactly like // flushNow does, so it can't poison every later sync, and log it. lastSent.set(recKey(ch.col, ch.id), JSON.stringify(ch.record)); recordSyncIssue(ch.col, ch.id, st, e.message); } } } // Re-bootstrap so this station ends up mirroring the shared server. if (AUTH_TOKEN) { const b = await loadFromServer(); if (b && !b.ok) errors.push("Re-sync from server failed: " + (b.reason || "unknown")); } notify(); return { ok: errors.length === 0, pushed, errors }; }, async login(user, password) { try { const last = window.K2BStore && window.K2BStore.lastStore(); // RC35 (brd): land in the last store used on this PC (D21) const r = await apiPost("/login", last ? { user, password, store: last } : { user, password }); // RC22 (own): which ways to get a code this login has (text message / authenticator / backup code / one-time pass), the masked phone if (r && r.mfaRequired) return { ok: true, mfaRequired: true, mfaToken: r.mfaToken, user: r.user, role: r.role, methods: r.methods || ["totp"], phone: r.phone || null, enrollTotp: !!r.enrollTotp, message: r.message || "", smsReady: r.smsReady !== false }; if (r && r.token) { __offlineByChoice = false; // real sign-in ends an offline-by-choice session setAuthToken(r.token); dmsStoreSignedIn(r); dmsOwnSetMe({ owner: !!r.owner }); currentUser = { user: r.user || user, role: normRole(r.role) }; await this.boot(); return { ok: true, token: r.token, user: r.user, role: r.role }; } return { ok: false, msg: "Login failed", serverReached: true }; } catch (e) { // 401/403 = server reached and rejected the credentials. // Anything else (404, network error, no server) = server NOT reachable, // so the caller should use its offline/demo login instead. const reached = (e.status === 401 || e.status === 403 || e.status === 429); // RC36 (sec36, S2): 429 = the server's sign-in wait (its sentence is the message) — never the offline login return { ok: false, msg: e.message || "Login failed", serverReached: reached, retryInSec: e.body && e.body.retryInSec }; } }, async loginMfa(mfaToken, otp, method) { try { const last = window.K2BStore && window.K2BStore.lastStore(); const b = method ? { mfaToken, otp, method } : { mfaToken, otp }; if (last) b.store = last; const r = await apiPost("/mfa/verify", b); // RC22 (own): + the method (sms | totp | backup | pass); RC35: + the store if (r && r.token) { __offlineByChoice = false; setAuthToken(r.token); dmsStoreSignedIn(r); dmsOwnSetMe({ owner: !!r.owner }); currentUser = { user: r.user, role: normRole(r.role) }; await this.boot(); return { ok: true, token: r.token, user: r.user, role: r.role }; } return { ok: false, msg: "Verification failed", serverReached: true }; } catch (e) { const reached = (e.status === 401 || e.status === 403); return { ok: false, msg: e.message || "MFA failed", serverReached: reached }; } }, async mfaEnroll() { try { return { ok: true, ...(await apiPost("/mfa/enroll")) }; } catch (e) { return { ok: false, msg: e.message }; } }, // RC22 (own): a code by text message at sign-in; setting up the authenticator AT sign-in (the owner required it and allowed the app) async mfaSendSms(mfaToken) { try { return Object.assign({ ok: true }, await apiPost("/mfa/sms/send", { mfaToken })); } catch (e) { return { ok: false, msg: e.message, retryInSec: e.body && e.body.retryInSec, code: e.body && e.body.code }; } }, async mfaEnrollAtSignIn(mfaToken) { try { return Object.assign({ ok: true }, await apiPost("/mfa/enroll", { mfaToken })); } catch (e) { return { ok: false, msg: e.message }; } }, async mfaConfirmAtSignIn(mfaToken, otp) { try { const last = window.K2BStore && window.K2BStore.lastStore(); const r = await apiPost("/mfa/confirm", last ? { mfaToken, otp, store: last } : { mfaToken, otp }); if (r && r.token) { __offlineByChoice = false; setAuthToken(r.token); dmsStoreSignedIn(r); dmsOwnSetMe({ owner: !!r.owner }); currentUser = { user: r.user, role: normRole(r.role) }; await this.boot(); return { ok: true, token: r.token, user: r.user, role: r.role }; } return { ok: false, msg: "Set-up failed" }; } catch (e) { return { ok: false, msg: e.message }; } }, isOwner() { return !!dmsOwnMe.owner; }, myGrants() { return { grant: dmsOwnMe.grants.grant.slice(), revoke: dmsOwnMe.grants.revoke.slice() }; }, async mfaConfirm(otp) { try { await apiPost("/mfa/confirm", { otp }); return { ok: true }; } catch (e) { return { ok: false, msg: e.message }; } }, // ── team / server user management (Settings → Users, admin-only) ── async teamList() { try { return await apiGet("/team/list"); } catch (e) { return { ok: false, error: e.message }; } }, async teamCreate(u) { try { return await apiPost("/team/create", u); } catch (e) { return { ok: false, error: e.message }; } }, async teamUpdate(patch) { try { return await apiPost("/team/update", patch); } catch (e) { return { ok: false, error: e.message }; } }, async teamSetPassword(id, password) { try { return await apiPost("/team/set-password", { id, password }); } catch (e) { return { ok: false, error: e.message }; } }, // ── encryption-at-rest control ── isEncryptedAtRest() { try { return isEnc(localStorage.getItem(dmsKey())); } catch (e) { return false; } }, isLocked() { return lockedVault; }, // Decrypt the at-rest vault into memory (after JBVault.unlock/resume succeeds). async hydrate() { const fresh = await loadAsync(); if (fresh) { state = fresh; lockedVault = false; __lockedTold = false; notify(); return { ok: true }; } return { ok: false }; }, // Does the start-up gate have to ask for the passphrase? Yes when the stored copy is an // envelope OR encryption at rest is turned on for this PC (even with no copy on disk, e.g. // right after a clean sign-off) — otherwise the first save after sign-in would find the // vault locked and nothing could be cached. needsUnlock() { return storedCacheKind() === "encrypted" || vaultCfgEnabled(); }, // What this PC's saved copy looks like right now (Security screen + tests). cacheStatus() { let owner = null; try { owner = localStorage.getItem(dmsWhoKey()); } catch (e) {} return { encryption: vaultCfgEnabled() ? "on" : "off", vault: vault() ? (vaultOpen() ? "unlocked" : "locked") : "n/a", stored: storedCacheKind(), lastWrite: __lastCacheWrite, owner }; }, // Enable encryption on the currently-loaded (plaintext) data and rewrite it. async enableEncryption(passphrase, remember) { if (!vault()) return { ok: false, msg: "Crypto module not loaded." }; if (lockedVault || (state && state.__locked)) return { ok: false, msg: "Unlock this PC's data first." }; const r = await vault().enable(passphrase, remember); if (!r.ok) return r; lockedVault = false; __lockedTold = false; logAudit("Encryption Enabled", "AES-256-GCM at rest"); const w = await cacheWrite(); if (!w.ok) { // Encryption is on but the copy could not be written: leave NO plaintext copy behind. try { localStorage.removeItem(dmsKey()); localStorage.removeItem(dmsWhoKey()); } catch (e) {} notify(); return { ok: false, msg: "Encryption is on, but this PC's copy could not be written (" + (w.reason || "unknown") + "). It will be written at the next save." }; } notify(); return { ok: true, msg: "Data is now encrypted at rest (AES-256-GCM)." }; }, // The ONLY legitimate downgrade of an encrypted copy to plaintext — audited, and only with the vault open. async disableEncryption() { if (!vault()) return { ok: false, msg: "Crypto module not loaded." }; if (lockedVault || (state && state.__locked) || (vaultCfgEnabled() && !vaultOpen())) return { ok: false, msg: "Unlock this PC's data first (reload and enter the data passphrase), then turn encryption off." }; logAudit("Encryption Disabled", "Data stored in plaintext"); await vault().disable(); const w = await cacheWrite({ downgrade: true }); lockedVault = false; __lockedTold = false; notify(); return w.ok ? { ok: true, msg: "Encryption disabled — data stored in plaintext." } : { ok: false, msg: "Encryption is off, but this PC's copy could not be rewritten (" + (w.reason || "unknown") + ")." }; }, // Change the passphrase AND re-encrypt this PC's copy at once (a copy left encrypted with the // old key would be unreadable after a reload until the next save). async changePassphrase(oldPass, newPass, remember) { if (!vault()) return { ok: false, msg: "Crypto module not loaded." }; if (lockedVault || (state && state.__locked)) return { ok: false, msg: "Unlock this PC's data first." }; const r = await vault().changePassphrase(oldPass, newPass, remember || "browser"); if (!r.ok) return r; logAudit("Encryption Passphrase Changed", "This PC's copy re-encrypted"); const w = await cacheWrite(); notify(); return w.ok ? { ok: true, msg: "Passphrase changed." } : { ok: false, msg: "Passphrase changed, but this PC's copy could not be rewritten (" + (w.reason || "unknown") + ")." }; }, // Forget everything local (unlock gate → "Wipe & start fresh"): this PC's copy and its owner // label, the vault settings and cached key, and every per-session work key. The server is not touched. wipeLocalCache() { try { const v = vault(); if (v && typeof v.forget === "function") v.forget(); else if (v) v.lock(); } catch (e) {} try { localStorage.removeItem(dmsKey()); localStorage.removeItem(dmsWhoKey()); } catch (e) {} for (const k of ["jbrews-vault-cfg", "jbrews-vault-verify", "jbrews-vault-salt", "jbrews-vault-key"]) { try { localStorage.removeItem(k); sessionStorage.removeItem(k); } catch (e) {} } clearLocalWork(); state = _blank(); lockedVault = false; __lockedTold = false; lastSent.clear(); lastRev.clear(); return { ok: true }; }, clearLocalWork, // ── portable backup (real migration in/out) ── exportBackup() { return { format: "JBREWS-DMS", version: 2, dealer: (window.K2BStore && window.K2BStore.pageId()) || "", exported: new Date().toISOString(), station: STATION, data: state }; }, // Bring a backup / old-DMS export into the ledger. // mode "replace" → swap the whole ledger; "merge" → append records. importBackup(payload, mode) { const incoming = payload && payload.data ? payload.data : payload; if (!incoming || typeof incoming !== "object") return { ok: false, msg: "Unrecognized backup format." }; // RC35 (brd): a backup made in another store never loads here (an RC34 backup - no store - only into the primary store) { const here = window.K2BStore && window.K2BStore.pageId(), from = payload && payload.format === "JBREWS-DMS" ? String(payload.dealer || "") : ""; if (payload && payload.format === "JBREWS-DMS" && here && (from ? from !== here : !window.K2BStore.isPrimary())) return { ok: false, msg: "This backup belongs to another store. Nothing was changed." }; } const counts = {}; if (mode === "merge") { const listKeys = ["inventory", "deals", "repairOrders", "parts", "partsOrders", "cores", "technicians", "appointments", "payables", "arCustomers", "arLetters", "audit"]; listKeys.forEach(k => { if (Array.isArray(incoming[k]) && incoming[k].length) { state[k] = state[k] || []; const existing = new Set(state[k].map(r => r && (r.id || r.acct || r.stock || r.po || r.part || JSON.stringify(r)))); let added = 0; incoming[k].forEach(r => { const key = r && (r.id || r.acct || r.stock || r.po || r.part || JSON.stringify(r)); if (!existing.has(key)) { state[k].push(r); existing.add(key); added++; } }); if (added) counts[k] = added; } }); } else { for (const k in incoming) state[k] = incoming[k]; Object.keys(incoming).forEach(k => { if (Array.isArray(incoming[k])) counts[k] = incoming[k].length; }); } logAudit("Data Import", `${mode === "merge" ? "Merged" : "Replaced"} — ` + Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(", ")); persist(); notify(); const total = Object.values(counts).reduce((s, v) => s + v, 0); return { ok: true, msg: `Imported ${total.toLocaleString()} records.`, counts }; }, // ── reset / wipe ── reseed() { if (!dmsPrimaryStore()) return { ok: false, msg: DMS_PRIMARY_ONLY }; state = JSON.parse(JSON.stringify(DMS_SEED)); persist(); notify(); }, // load demo dataset loadSampleData() { if (!dmsPrimaryStore()) return { ok: false, msg: DMS_PRIMARY_ONLY }; try { localStorage.removeItem("jbrews-fresh-start"); } catch (e) {} state = JSON.parse(JSON.stringify(DMS_SEED)); logAudit("Sample Data Loaded", "Demo dealership dataset"); persist(); notify(); return { ok: true, msg: "Sample data loaded. Reload the page so all modules pick up the demo dataset." }; }, startBlank() { state = _blank(); logAudit("Started Blank", "All data cleared to empty"); persist(); notify(); return { ok: true, msg: "Started with a blank dealership." }; }, // Wipe EVERYTHING — local AND server. Blanks local state, then empties the // server store so no station can re-download old data: tries the bulk // POST /reset route first (server broadcasts {type:"reset"}), and falls // back to deleting every record collection-by-collection. Resolves only // when the server is actually empty — callers must AWAIT this before // reloading the page, or the interrupted flush leaves server data behind // (that was the "wipe doesn't wipe" bug). async serverWipe(onProgress) { if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } state = _blank(); offlineCacheWrite(); notify(); if (!AUTH_TOKEN) { lastSent.clear(); return { ok: true, server: false, msg: "Local data wiped. (Not signed in to the server — server store untouched.)" }; } // Fast path: dedicated bulk-reset route. try { await apiPost("/reset", { confirm: true }); lastSent.clear(); return { ok: true, server: true, msg: "All dealership data wiped — local and server. Every account is at $0.00." }; } catch (e) { // RC36 (sec36, S4): the server REFUSED the wipe (only the owner with a fresh code, no money posted) — never fall back to deleting record by record if (e && (e.status === 401 || e.status === 403 || e.status === 409 || e.status === 428)) { try { await loadFromServer(); } catch (x) {} notify(); return { ok: false, server: true, refused: true, code: e.body && e.body.code, msg: e.message || "The server refused \"Start over\". Nothing was deleted." }; } /* route missing (404) or older server — fall through */ } // Fallback: enumerate ids from the server itself + lastSent, delete all. let deleted = 0, failed = 0; const cols = Object.keys(SERVER_COLLECTIONS); for (let ci = 0; ci < cols.length; ci++) { const col = cols[ci], def = SERVER_COLLECTIONS[col]; if (onProgress) { try { onProgress(col, ci + 1, cols.length); } catch (e) {} } if (def.singleton) { try { await apiPost("/" + col, {}); lastSent.set(recKey(col, "__singleton__"), JSON.stringify({})); } catch (e) { failed++; } continue; } const ids = new Set(); for (const k of lastSent.keys()) { const [c, id] = k.split("|"); if (c === col && id !== "__singleton__") ids.add(id); } try { const r = await apiGet("/" + col); const arr = Array.isArray(r) ? r : (r && (r.records || r.rows)) || []; for (const rec of arr) { const id = rec && _recId(def, rec); if (id != null && id !== "") ids.add(String(id)); } } catch (e) { /* list route unavailable — lastSent ids still cover our own writes */ } for (const id of ids) { try { await apiDelete("/" + col + "/" + encodeURIComponent(id)); lastSent.delete(recKey(col, id)); deleted++; } catch (e) { if (e && e.status === 404) lastSent.delete(recKey(col, id)); else failed++; } } } return failed ? { ok: false, server: true, deleted, failed, msg: "Wiped " + deleted + " records but " + failed + " failed — run Wipe again to finish." } : { ok: true, server: true, deleted, msg: "All dealership data wiped — local and server (" + deleted + " records deleted). Every account is at $0.00." }; }, clear() { state = _blank(); persist(); notify(); }, // ── Import REAL legacy data (from the old Btrieve PTL/SSM files) ── // Merges window.LEGACY_SEED into the current state by id (no wipe): // parts keyed by `part`, repairOrders keyed by `id`. Existing records win // unless overwrite=true. importLegacy(overwrite = false) { const seed = window.LEGACY_SEED; if (!seed) return { ok: false, msg: "Legacy data file not loaded." }; const mergeBy = (coll, key, rows) => { if (!Array.isArray(state[coll])) state[coll] = []; const idx = new Map(state[coll].map(r => [r[key], r])); let added = 0, updated = 0; for (const r of rows) { if (idx.has(r[key])) { if (overwrite) { Object.assign(idx.get(r[key]), r); updated++; } } else { state[coll].push(r); idx.set(r[key], r); added++; } } return { added, updated }; }; const p = mergeBy("parts", "part", seed.parts || []); const r = mergeBy("repairOrders", "id", seed.repairOrders || []); if (state.meta) state.meta.legacyImported = true; logAudit("Legacy Data Imported", `PTL parts +${p.added}, SSM ROs +${r.added}`); persist(); notify(); return { ok: true, msg: `Imported ${p.added} parts and ${r.added} service ROs from legacy files.` }; }, // ════════════════════════════════════════════════════════════════ // CASCADE: Post a vehicle deal — RC21 (deals): ONE SERVER COMMAND (POST /api/deals/post). // The server checks the role for THIS action (the office), re-reads the deal, its vehicle, its trade and its recorded payments // under row locks, re-computes the deal with src/lib/deal-calc.js (the same file as DealCalc.jsx.txt), refuses an impossible deal // BEFORE anything changes (a trade ACV of $0, a vehicle with no cost, a cash balance still due, a stale amount financed / payment, // an "Other" charge with no account …), and books the sale entry (rebate → factory receivable 261, trade over-allowance on the sale // account, trade into used inventory at ACV, payoff 303, finance → contracts in transit 205), the F&I income and the commission // accrual, relieves the vehicle, puts the trade-in in inventory and writes the receivables, the deal and the audit rows together — // or nothing. Nothing changes on this station until the server answers "committed"; then the committed records replace the // local copies. A dropped answer is looked up by its key (GET /api/deals/op/) — never posted twice. // ════════════════════════════════════════════════════════════════ async postDeal(dealId, opts) { opts = opts || {}; // the office posts (RC16 decision): a desk manager is told so — the same words as RC16 – RC20 if (!api.can("dealPost")) return { ok: false, denied: true, msg: `Posting a deal books journal entries, which your role (${currentRole() || "this login"}) cannot write — ask the office (Admin / F&I, Office Manager or Accounting) to post and fund it.` }; // RC22 (own): dealPost (the owner may give it to one login / take it away) const deal = (state.deals || []).find(d => d.id === dealId); if (!deal) return { ok: false, msg: "Deal not found." }; if (deal.postedToGL) return { ok: false, msg: `${deal.id} is already posted.` }; if (!AUTH_TOKEN || __offlineByChoice) return { ok: false, msg: "Posting a deal books it on the server, which needs the connection. Connect to the server, then post it." }; const t0 = Date.now(); const saved = await api._fopsSaveFirst("deals", dealId); if (!saved.ok) return { ok: false, msg: saved.msg }; { const refused = api._dealRefusedSince(dealId, t0); if (refused) return { ok: false, code: "save_refused", msg: refused + " Nothing was posted." }; } const key = opts.idemKey || api._newIdemKey("dp"); const r = await api._command("/deals/post", { dealId, baseRev: saved.rev }, key, "/deals/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 posting ${dealId} again, look at it in the Deal Pipeline — if it shows Posted it was posted. (The office can look up ${key}.)` }; if (b.record && b.rev != null) api._applyServerRecords([{ collection: "deals", id: dealId, rev: b.rev, record: b.record }]); notify(); return { ok: false, key, code: b.code, problems: b.problems, msg: b.error || (e && e.message) || "The server refused to post this deal." }; } const res = r.res; api._applyServerRecords(res.records); const warnings = (res.warnings || []).map(w => ({ code: "POST", msg: w, ts: new Date().toISOString() })); if (typeof window !== "undefined" && window.jbSound) window.jbSound.chaching(); logAudit("Deal Posted", `${dealId} → ${(res.journalIds && res.journalIds.sale) || "?"} (${res.kind})${r.recovered ? " — answer recovered after a dropped connection" : ""}`); persist(); notify(); return { ok: true, key, kind: res.kind, journalIds: res.journalIds, totals: res.totals, warnings, records: res.records, recovered: !!r.recovered, msg: (res.message || `${dealId} posted.`) + (warnings.length ? " ⚠ " + warnings.map(w => w.msg).join(" ") : "") }; }, // (RC21) The deal save that _fopsSaveFirst pushed was REFUSED by the server (its copy came back): never act on the server's older // copy as if it were the screen — say why and stop. → the refusal message or null. _dealRefusedSince(dealId, t0) { const x = (__financialRefusals || []).filter(r => r && r.collection === "deals" && String(r.id) === String(dealId) && r.at >= t0).pop(); return x ? (x.error || "The server refused the deal's last save.") : null; }, // What the server would refuse for this deal — the same calculation (DealCalc = src/lib/deal-calc.js), on this station's copies of // the deal, its unit and its recorded payments. A PREVIEW for the close gates: the server re-checks everything when it posts. dealPostPreview(dealId) { const d = (state.deals || []).find(x => x.id === dealId); if (!d || !window.DealCalc) return null; const unit = d.stock ? (api._unit(d.stock) || (state.inventory || []).find(u => u.stock === d.stock) || null) : null; const rc = (Array.isArray(d.receipts) ? d.receipts : []).filter(x => x && !x.void).map(x => ({ id: x.id, amountCents: window.DealCalc.toCents(x.amount), method: x.method, ref: x.ref, date: x.date })); return window.DealCalc.postPlan(d, { unit, receipts: rc }); }, // ════════════════════════════════════════════════════════════════ // RC23 (corr): CORRECTING A POSTED DEAL — four SERVER commands (the office: Admin / F&I, Office Manager, Accounting): // unwindDeal(dealId, reason) every entry of the posting reversed (linked), the unit back in stock, the trade-in // out of stock (only while untouched), the commission reversed, the customer's money // HELD (331); the deal becomes "Unwound" — change it and post it again // reverseDealFunding(dealId, fundingId, reason) a lender funding reversed (CIT back) — a funded deal is unwound only after this // refundDeal(dealId, { amount, date, method, ref, reason }) money held for the customer paid back (a check = the check command) // cancelDealProduct(dealId, { productId, cancelDate, customerRefund, chargebackToDealer, refundTo, reason }) // correctionPreview(op, body) = the confirm step: the server runs the command and rolls it back — exactly what it would do. // Nothing changes on this station until the server answers "committed" (a dropped answer is looked up by its key). // ════════════════════════════════════════════════════════════════ CORRECTION_PATHS: { unwind: "/deals/unwind", "fund-reverse": "/deals/fund/reverse", refund: "/deals/refund", "product-cancel": "/deals/product-cancel" }, dealHeldCents(d) { const c = window.DealCalc ? window.DealCalc.toCents(d && d.heldForCustomer) : Math.round((+((d && d.heldForCustomer) || 0)) * 100); return isFinite(c) ? c : 0; }, async correctionPreview(op, body) { if (!api.can("dealPost")) return { ok: false, denied: true, msg: `Correcting a posted deal is done by the office (Admin / F&I, Office Manager or Accounting) — your role (${currentRole() || "this login"}) cannot.` }; if (!AUTH_TOKEN || __offlineByChoice) return { ok: false, msg: "A correction is done by the server, which needs the connection. Connect to the server first." }; try { const r = await apiFetch("/deals/correction/preview", { method: "POST", body: { op, body }, noOwnerOverride: true }); return Object.assign({ ok: true }, r); } catch (e) { const b = (e && e.body) || {}; return { ok: false, code: b.code, msg: b.error || (e && e.message) || "The server refused this correction.", ownerOverride: b.ownerOverride || null, problems: b.problems }; } }, async _correct(op, body, prefix) { if (!api.can("dealPost")) return { ok: false, denied: true, msg: `Correcting a posted deal is done by the office (Admin / F&I, Office Manager or Accounting) — your role (${currentRole() || "this login"}) cannot.` }; if (!AUTH_TOKEN || __offlineByChoice) return { ok: false, msg: "A correction is done by the server, which needs the connection. Connect to the server first." }; const key = (body && body.idemKey) || api._newIdemKey(prefix || "dx"); const b = Object.assign({}, body); delete b.idemKey; const r = await api._command(api.CORRECTION_PATHS[op], b, key, "/deals/op/"); if (!r.ok) { const e = r.err; const eb = (e && e.body) || {}; if (r.unknown) return { ok: false, unknown: true, key, msg: `The server did not answer. Before trying again, look at deal ${b.dealId} — its correction history shows whether it went through. (The office can look up ${key}.)` }; if (eb.record && eb.rev != null && b.dealId) api._applyServerRecords([{ collection: "deals", id: b.dealId, rev: eb.rev, record: eb.record }]); notify(); return { ok: false, key, code: eb.code, problems: eb.problems, msg: eb.error || (e && e.message) || "The server refused this correction." }; } const res = r.res; api._applyServerRecords(res.records); for (const x of res.deleted || []) { try { mergeRecord(x.collection, x.id, null); } catch (err) {} } logAudit("Deal Corrected", `${b.dealId} · ${op}${r.recovered ? " — answer recovered after a dropped connection" : ""} · ${res.message || ""}`.slice(0, 300)); persist(); notify(); return { ok: true, key, res, msg: res.message || "Done.", warnings: res.warnings || [] }; }, unwindDeal(dealId, reason, opts) { const d = (state.deals || []).find(x => x.id === dealId); return api._correct("unwind", { dealId, reason, baseRev: d ? (lastRev.get(recKey("deals", dealId)) || undefined) : undefined, idemKey: opts && opts.idemKey }, "du"); }, reverseDealFunding(dealId, fundingId, reason, opts) { return api._correct("fund-reverse", { dealId, fundingId, reason, idemKey: opts && opts.idemKey }, "dfr"); }, refundDeal(dealId, o) { o = o || {}; return api._correct("refund", { dealId, amount: o.amount, date: o.date, method: o.method, ref: o.ref || "", reason: o.reason, idemKey: o.idemKey }, "drf"); }, cancelDealProduct(dealId, o) { o = o || {}; return api._correct("product-cancel", { dealId, productId: o.productId, cancelDate: o.cancelDate, customerRefund: o.customerRefund, chargebackToDealer: o.chargebackToDealer, refundTo: o.refundTo, reason: o.reason, idemKey: o.idemKey }, "dpc"); }, /** the body each correction sends (the screens build the same one for the preview and the command) */ correctionBody(op, dealId, f) { f = f || {}; if (op === "unwind") return { dealId, reason: f.reason, baseRev: lastRev.get(recKey("deals", dealId)) || undefined }; if (op === "fund-reverse") return { dealId, fundingId: f.fundingId, reason: f.reason }; if (op === "refund") return { dealId, amount: f.amount, date: f.date, method: f.method, ref: f.ref || "", reason: f.reason }; return { dealId, productId: f.productId, cancelDate: f.cancelDate, customerRefund: f.customerRefund, chargebackToDealer: f.chargebackToDealer, refundTo: f.refundTo, reason: f.reason }; }, // ════════════════════════════════════════════════════════════════ // RC27 (dsk): WE-OWES (due bills) — the server keeps them (routes/we-owes.js). A deal's we-owe lines (Profit panel) become // rows when the deal is POSTED; Service opens an internal RO from one, closing that RO marks it done (actual vs estimate). // The money (estimate / actual) only comes back to the roles that see deal cost. Nothing changes on this station until the // server answers "committed" (a dropped answer is looked up by its key — never done twice). // ════════════════════════════════════════════════════════════════ _weOwes: { list: [], at: 0, may: null, status: "open", msg: null }, weOwesCached() { return api._weOwes; }, async loadWeOwes(status, opts) { opts = opts || {}; if (!AUTH_TOKEN || __offlineByChoice) return { ok: false, msg: "The we-owes list is kept on the server — connect to the server first." }; const st = status || "open"; try { const r = await apiGet("/we-owes?status=" + encodeURIComponent(st) + (opts.dealId ? "&dealId=" + encodeURIComponent(opts.dealId) : "")); if (!opts.dealId) { api._weOwes = { list: r.weOwes || [], at: Date.now(), may: r.may || null, status: st, msg: null, openEstimateCents: r.openEstimateCents }; notify(); } return Object.assign({ ok: true }, r); } catch (e) { const b = (e && e.body) || {}; const msg = b.error || (e && e.message) || "Could not load the we-owes."; if (!opts.dealId) { api._weOwes = Object.assign({}, api._weOwes, { msg, at: Date.now() }); notify(); } return { ok: false, status: e && e.status, msg }; } }, async weOweDetail(id) { try { return Object.assign({ ok: true }, await apiGet("/we-owes/" + encodeURIComponent(id))); } catch (e) { const b = (e && e.body) || {}; return { ok: false, msg: b.error || (e && e.message) || "Could not load the we-owe." }; } }, async _weOweCmd(path, body, prefix) { if (!AUTH_TOKEN || __offlineByChoice) return { ok: false, msg: "The we-owes are kept on the server — connect to the server first." }; const key = (body && body.idemKey) || api._newIdemKey(prefix || "wo"); const b = Object.assign({}, body); delete b.idemKey; const r = await api._command(path, b, key, "/we-owes/op/"); if (!r.ok) { if (r.unknown) return { ok: false, unknown: true, key, msg: `The server did not answer. Before trying again, open the we-owe — its history shows whether it went through. (The office can look up ${key}.)` }; const eb = (r.err && r.err.body) || {}; if (eb.weOwe || eb.code === "stale") api.loadWeOwes(api._weOwes.status || "open"); return { ok: false, key, code: eb.code, msg: eb.error || (r.err && r.err.message) || "The server refused this change." }; } api._applyServerRecords(r.res.records || []); logAudit("We-Owe", `${b.id} · ${path.replace("/we-owes/", "")}${r.recovered ? " — answer recovered after a dropped connection" : ""} · ${r.res.message || ""}`.slice(0, 300)); persist(); notify(); api.loadWeOwes(api._weOwes.status || "open"); return { ok: true, key, res: r.res, roId: r.res.roId, msg: r.res.message || "Done." }; }, weOweSetStatus(id, status, o) { o = o || {}; return api._weOweCmd("/we-owes/status", { id, status, scheduledFor: o.scheduledFor || undefined, note: o.note || undefined, actual: o.actual === undefined || o.actual === "" ? undefined : o.actual, version: o.version, idemKey: o.idemKey }, "wos"); }, weOweOpenRo(id, o) { o = o || {}; return api._weOweCmd("/we-owes/open-ro", { id, version: o.version, idemKey: o.idemKey }, "wor"); }, weOweVoid(id, reason, o) { o = o || {}; return api._weOweCmd("/we-owes/void", { id, reason, version: o.version, idemKey: o.idemKey }, "wov"); }, // ── RC28 (svc): the SERVICE PACK — maintenance schedules, the service menu + its presentation, reminders, Sunbit. Everything lives on the // server (/api/svc/…): this station only shows what the server answers (prices from the server's menu, never a cost). The Sunbit box // for customer documents is kept here (api._svc.badge) so a print can carry it at once. _svc: { config: null, badge: null, at: 0, msg: null }, // what this login may do: the server's own answer (GET /api/svc/config "may") once loaded — the server decides every request anyway _svcMay(k, dflt) { const m = api._svc.config && api._svc.config.may; return m && m[k] !== undefined ? !!m[k] : dflt; }, svcCanPresent() { return api._svcMay("present", (!!(api.can && (api.can("service") || api.can("acct") || api.isAdmin())) || currentRole() === "Technician") && currentRole() !== "Sales"); }, svcCanLookup() { return api._svcMay("lookup", api.svcCanPresent()); }, // Service Dept › Maintenance schedules (techs too — RC28 fix, svc-aud finding 2) // (the page canonicalises "Service Manager" to "Service Advisor" — normRole — so the RAW role comes from the sign-in token first; RC28 fix, svc-aud finding 5) _svcRawServiceManager() { const ti = decodeTokenInfo(); const raw = String((ti && !ti.expired && ti.role) || (currentUser && currentUser.role) || "").trim(); return /^service\s*(manager|mgr)$/i.test(raw); }, svcPackCanSetup() { return api._svcMay("setup", !!(api.isAdmin && api.isAdmin()) || !!(api.can && api.can("glEdit")) || api._svcRawServiceManager()); }, // (ServiceCore keeps svcCanSetup for its own settings) svcIsOffice() { return api._svcMay("office", !!(api.isAdmin && api.isAdmin()) || !!(api.can && api.can("acct"))); }, svcCanCollect() { return api._svcMay("collect", api.svcIsOffice() || api._svcRawServiceManager()); }, // "Sunbit to collect": the office and the Service Manager (view) svcCanRemind() { return api._svcMay("remind", !!(api.can && (api.can("service") || api.can("acct")))); }, async _svcGet(path) { if (!AUTH_TOKEN || __offlineByChoice) return { ok: false, msg: "The service pack is kept on the server — connect to the server first." }; try { return Object.assign({ ok: true }, await apiGet(path)); } catch (e) { const b = (e && e.body) || {}; return Object.assign({}, b, { ok: false, status: e && e.status, msg: b.error || (e && e.message) || "The server could not answer." }); } }, async svcLoadConfig() { const r = await api._svcGet("/svc/config"); if (r.ok) { api._svc.config = r; api._svc.badge = r.sunbit || api._svc.badge; api._svc.at = Date.now(); notify(); } else api._svc.msg = r.msg; return r; }, async svcLoadBadge() { const r = await api._svcGet("/svc/sunbit/badge"); if (r.ok) { api._svc.badge = r; try { window.__svcBadge = r; } catch (e) {} notify(); } return r; }, svcBadge() { return api._svc.badge; }, svcSchedule(vin, miles, o) { o = o || {}; const q = ["vin=" + encodeURIComponent(vin || ""), "miles=" + encodeURIComponent(miles == null ? "" : miles)]; if (o.severe !== undefined) q.push("severe=" + (o.severe ? 1 : 0)); if (o.source) q.push("source=" + encodeURIComponent(o.source)); if (o.months) q.push("months=" + encodeURIComponent(o.months)); return api._svcGet("/svc/schedule?" + q.join("&")); }, svcPresentation(roId, miles) { return api._svcGet("/svc/present?roId=" + encodeURIComponent(roId) + (miles ? "&miles=" + encodeURIComponent(miles) : "")); }, svcDeclines(vin) { return api._svcGet("/svc/declines?vin=" + encodeURIComponent(vin)); }, svcReminders(view) { return api._svcGet("/svc/reminders?view=" + encodeURIComponent(view || "due")); }, svcReminderLog(vin) { return api._svcGet("/svc/reminders/log" + (vin ? "?vin=" + encodeURIComponent(vin) : "")); }, svcToCollect(all) { return api._svcGet("/svc/sunbit/to-collect" + (all ? "?all=1" : "")); }, async _svcCmd(path, body, prefix) { if (!AUTH_TOKEN || __offlineByChoice) return { ok: false, msg: "The service pack is kept on the server — connect to the server first." }; const key = (body && body.idemKey) || api._newIdemKey(prefix || "svc"); const b = Object.assign({}, body); delete b.idemKey; const r = await api._command(path, b, key, "/svc/op/"); if (!r.ok) { if (r.unknown) return { ok: false, unknown: true, key, msg: `The server did not answer. Before trying again, look at the RO / the reminder log — it shows whether it went through. (The office can look up ${key}.)` }; const eb = (r.err && r.err.body) || {}; if (eb.record && eb.rev != null && b.roId) api._applyServerRecords([{ collection: "repairOrders", id: b.roId, rev: eb.rev, record: eb.record }]); return Object.assign({}, eb, { ok: false, key, code: eb.code, msg: eb.error || (r.err && r.err.message) || "The server refused this." }); } api._applyServerRecords(r.res.records || []); persist(); notify(); return Object.assign({}, r.res, { ok: r.res.ok !== false, key, msg: r.res.message || "Done." }); }, async svcPresentCommit(roId, sel) { sel = sel || {}; if (!api.svcCanPresent()) return { ok: false, denied: true, msg: "Your role can't present the service menu." }; const saved = await api._fopsSaveFirst("repairOrders", roId); if (!saved.ok) return { ok: false, msg: saved.msg }; const r = await api._svcCmd("/svc/present", { roId, baseRev: saved.rev, accept: sel.accept || [], packageId: sel.packageId || null, decline: sel.decline || [], odometer: sel.odometer || undefined, idemKey: sel.idemKey }, "mp"); if (r.ok) logAudit("Service Menu", `${roId}: ${r.message || ""}`.slice(0, 300)); return r; }, svcSaveSetting(key, value, version) { return api._svcCmd("/svc/settings/" + encodeURIComponent(key), { value, version }, "ss").then(r => { if (r.ok) { api.svcLoadConfig(); if (key === "sunbit") api.svcLoadBadge(); } return r; }); }, svcRemind(vin) { return api._svcCmd("/svc/reminders/send", { vin }, "rm"); }, async svcRemindBatch(view) { if (!AUTH_TOKEN || __offlineByChoice) return { ok: false, msg: "Reminders are sent by the server — connect to the server first." }; const key = api._newIdemKey("rb"); try { const r = await apiPostH("/svc/reminders/batch", { view: view || "both" }, { "Idempotency-Key": key }); return Object.assign({ ok: true, key }, r, { msg: r.message }); } catch (e) { const b = (e && e.body) || {}; return Object.assign({}, b, { ok: false, key, msg: b.error || (e && e.message) || "The batch was refused." }); } }, // RC27 (dsk): the per-lender default fee (Banks / Lenders) — kept on the server, suggested by the desk when the lender is picked _lenderFees: { list: [], at: 0, msg: null }, lenderFeesCached() { return api._lenderFees; }, async loadLenderFees() { if (!AUTH_TOKEN || __offlineByChoice) return { ok: false, msg: "The lender fees are kept on the server — connect first." }; try { const r = await apiGet("/deals/lender-fees"); api._lenderFees = { list: r.fees || [], at: Date.now(), msg: null }; notify(); return { ok: true, fees: r.fees || [] }; } catch (e) { const b = (e && e.body) || {}; api._lenderFees = Object.assign({}, api._lenderFees, { at: Date.now(), msg: b.error || (e && e.message) || "" }); return { ok: false, msg: b.error || (e && e.message) || "Could not load the lender fees." }; } }, lenderFeeFor(lenderName) { const k = String(lenderName || "").trim().toUpperCase(); return k ? ((api._lenderFees.list || []).find(f => f && f.key === k) || null) : null; }, async saveLenderFee(o) { o = o || {}; if (!AUTH_TOKEN || __offlineByChoice) return { ok: false, msg: "The lender fees are kept on the server — connect first." }; try { const r = await apiPost("/deals/lender-fees", { lender: o.lender, kind: o.kind, amount: o.amount, points: o.points, passed: !!o.passed, note: o.note || "", remove: o.remove === true ? true : undefined }); await api.loadLenderFees(); return Object.assign({ ok: true, msg: r.message }, r); } catch (e) { const b = (e && e.body) || {}; return { ok: false, msg: b.error || (e && e.message) || "The server could not save the lender fee." }; } }, // New | Used from an inventory unit's type/condition fields. _dealTypeOfUnit(unit) { if (!unit) return ""; const t = String(unit.type || unit.condition || unit.newUsed || "").trim(); if (!t) return ""; return /^n|new/i.test(t) ? "New" : "Used"; }, // Fill/canonicalize the fields every downstream reader depends on (B-01, // B-02, B-07): cost + type from the unit, tradeACV/tradeAcv both spelled, // zip/postal both spelled. Never overwrites a value that is already set. _normalizeDeal(d) { if (!d) return d; const unit = d.stock ? api._unit(d.stock) : null; if (unit) { if (!(+d.cost > 0)) { const c = +(unit.cost || unit.invoice || 0); if (c > 0) { d.cost = +c.toFixed(2); d.costSource = "unit"; } else { try { console.warn("[DMS] deal " + d.id + ": unit " + d.stock + " has no cost — deal.cost left at 0"); } catch (e) {} } } if (!d.type) d.type = api._dealTypeOfUnit(unit); if (!d.vin && unit.vin) d.vin = unit.vin; } if (d.tradeACV != null && d.tradeACV !== "" && (d.tradeAcv == null || d.tradeAcv === "")) d.tradeAcv = +d.tradeACV || 0; else if (d.tradeAcv != null && d.tradeAcv !== "" && (d.tradeACV == null || d.tradeACV === "")) d.tradeACV = +d.tradeAcv || 0; if (d.zip && !d.postal) d.postal = d.zip; else if (d.postal && !d.zip) d.zip = d.postal; return d; }, // ════════════════════════════════════════════════════════════════ // K2B-003 (RC18) — CASH DEAL MONEY. What the customer OWES (totalDue), // what was actually RECEIVED (deal.receipts: one entry per payment the // office recorded — date, amount, how paid) and the balance due are // three separate things. Saving, printing or posting never records // money; only recordDealReceipt does. A cash deal's cashDown is the // money received (the readers that call it "cash collected" stay true). // ════════════════════════════════════════════════════════════════ _m2: (n) => (+n || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }), RECEIPT_METHODS: ["Cash", "Check", "Card", "Wire / ACH", "Other"], isCashDeal(d) { if (!d || /lease/i.test(String(d.dealType || d.mode || ""))) return false; if ((+d.amtFinanced || 0) > 0.005) return false; return /cash/i.test(String(d.dealType || "")) || d.stage === "Cash"; }, // K2B-003 round 2: what keeps an otherwise paid-up CASH deal from posting (postDeal refuses; dealCloseGates shows them as hard blocks). _cashPostProblems(d) { const out = []; if (!d || d.postedToGL || !api.isCashDeal(d)) return out; const acvRaw = d.tradeACV != null && d.tradeACV !== "" ? d.tradeACV : (d.tradeAcv != null && d.tradeAcv !== "" ? d.tradeAcv : null); if ((+d.tradeAllow || 0) > 0.005 && acvRaw != null && !((+acvRaw || 0) > 0.005)) out.push({ code: "TRADE_ACV", msg: `Enter the trade-in's ACV (actual cash value — what it goes into used inventory at) on the Cash Deal before posting this cash deal (trade allowance $${api._m2(d.tradeAllow)}, ACV $0.00).` }); if ((+d.otherSale || 0) > 0.005) out.push({ code: "OTHER_UNMAPPED", msg: `This cash deal has an "Other" charge of $${api._m2(d.otherSale)}. The GL Posting Map has no account for "Other" charges, so it cannot be posted — ask your accountant which account it belongs to, or put the amount on a line that has one (price, doc fee or a product).` }); return out; }, cashDealFigures(d) { if (!d) return null; const R2 = (n) => +(+n || 0).toFixed(2); const totalDue = R2(R2(d.salePrice) + R2(d.tax) + R2(d.docFee) + R2(d.titleFee) + R2(d.vscSale) + R2(d.gapSale) + R2(d.otherSale) + R2(d.tradePayoff) - R2(d.tradeAllow) - R2(d.rebate)); const recorded = Array.isArray(d.receipts); const receipts = recorded ? d.receipts.filter(r => r && !r.void) : []; // A deal POSTED before RC18 booked its cashDown as cash at posting — shown as booked, never changed. const postedLegacy = !!d.postedToGL && !recorded; // Saved before RC18 by the old Cash Deal screen: cashDown = the whole total due, no receipts → "not recorded". const legacy = !d.postedToGL && !recorded && ((+d.legacyCashDown || 0) > 0 || ((+d.cashDown || 0) > 0 && Math.abs(R2(d.cashDown) - totalDue) < 0.005)); const received = postedLegacy ? R2(d.cashDown) : R2(receipts.reduce((s, r) => s + R2(r.amount), 0)); // RC27 (dsk): the store's down payment assistance (Profit panel) is money the customer does not pay — the balance due is net of it const dpa = window.DealCalc && Array.isArray(d.costAdds) && d.costAdds.length ? R2(window.DealCalc.costAddsOf(d).dpaCents / 100) : 0; return { totalDue, received, balanceDue: R2(totalDue - dpa - received), receipts, legacy, postedLegacy, dpa, customerDue: R2(totalDue - dpa), legacyCashDown: legacy ? R2((+d.legacyCashDown || 0) > 0 ? d.legacyCashDown : d.cashDown) : 0 }; }, // Record ONE payment the customer made on a cash deal — RC21 (deals): a SERVER command (POST /api/deals/receipt, the office only). // The server checks the amount (dollars and cents, more than $0, not more than the balance due), the date (not in the future, not in // a closed month), how it was paid (RECEIPT_METHODS), stamps WHO recorded it from the sign-in (never from the request), keeps it in // the append-only payment history and gives the deal its new received / balance figures. `id` comes from the Record Payment dialog // and is the request's key, so a retry / double click records it once. async recordDealReceipt(dealId, rec) { if (!api.can("dealPost")) return { ok: false, denied: true, msg: `Recording a customer payment is done by the office (Admin / F&I, Office Manager or Accounting) — your role (${currentRole() || "this login"}) cannot.` }; const d = (state.deals || []).find(x => x.id === dealId); if (!d) return { ok: false, msg: "Deal not found." }; if (d.postedToGL) return { ok: false, msg: `${d.id} is already posted — its money is on the books. A correction goes through Accounting.` }; if (!api.isCashDeal(d)) return { ok: false, msg: `${d.id} is not a cash deal.` }; const r = rec || {}; // RC21 round 2 (attack D1b): commas only as thousands separators (1,500.00) and no spaces inside the digits — "1,00" or // "1 0 0" is refused (it used to be read as $100.00); the server reads the amount the same way (DealCalc.strictCents). const typed = String(r.amount == null ? "" : r.amount).trim(); const mAmt = /^\$?\s*(\d{1,3}(?:,\d{3})+|\d+)(\.\d{1,2})?$/.exec(typed); const raw = mAmt ? mAmt[1].replace(/,/g, "") + (mAmt[2] || "") : ""; if (!raw || !(+raw > 0)) return { ok: false, msg: "Enter the amount received — dollars and cents, more than $0.00 (like 1,500.00; a comma only between thousands)." }; const method = String(r.method || "").trim(); if (!api.RECEIPT_METHODS.includes(method)) return { ok: false, msg: "Pick how the customer paid (" + api.RECEIPT_METHODS.join(", ") + ")." }; const now = new Date(), pad = (n) => String(n).padStart(2, "0"); const date = String(r.date || "").trim() || (now.getFullYear() + "-" + pad(now.getMonth() + 1) + "-" + pad(now.getDate())); if (!AUTH_TOKEN || __offlineByChoice) return { ok: false, msg: "Recording a payment needs the server (it is the record of the money). Connect to the server, then record it." }; const t0 = Date.now(); const saved = await api._fopsSaveFirst("deals", dealId); if (!saved.ok) return { ok: false, msg: saved.msg }; { const refused = api._dealRefusedSince(dealId, t0); if (refused) return { ok: false, code: "save_refused", msg: refused + " The payment was not recorded." }; } // RC21 round 2 (attack C2): the Record Payment dialog's id keys the request so a retry after a LOST answer records it once. Once // the server has ANSWERED with a refusal (nothing was recorded — e.g. more than the balance due), the next press is a NEW request // under a new key (-2, -3 …): a corrected amount in the same dialog is recorded instead of "does not match the original". const keyBase = r.id ? ("rc-" + String(r.id)).slice(0, 100) : null; const tries = keyBase ? ((api._rcRefused || (api._rcRefused = {}))[keyBase] || 0) : 0; const key = keyBase ? keyBase + (tries ? "-" + (tries + 1) : "") : api._newIdemKey("rc"); const c = await api._command("/deals/receipt", { dealId, amount: raw, date, method, ref: String(r.ref || "").trim().slice(0, 40), baseRev: saved.rev }, key, "/deals/op/"); if (!c.ok) { const e = c.err; const b = (e && e.body) || {}; if (c.unknown) return { ok: false, unknown: true, key, msg: `The server did not answer. Before recording this payment again, look at the deal's payments — the office can look up ${key}.` }; if (keyBase && b.code !== "idempotency_pending") api._rcRefused[keyBase] = tries + 1; // answered: nothing recorded under this key if (b.record && b.rev != null) api._applyServerRecords([{ collection: "deals", id: dealId, rev: b.rev, record: b.record }]); notify(); return { ok: false, key, code: b.code, over: b.code === "over_balance", duplicate: b.code === "duplicate_receipt", msg: b.error || (e && e.message) || "The server did not record the payment." }; } const res = c.res; api._applyServerRecords(res.records); const nd = (state.deals || []).find(x => x.id === dealId) || d; const f = res.figures || {}; logAudit("Cash Receipt", `${dealId} — ${res.receiptId} · ${res.message || ""}`); persist(); notify(); return { ok: true, id: res.receiptId, receipt: res.receipt, rev: nd.rev, duplicate: !!res.replayed, key, figures: { totalDue: (f.totalDueCents || 0) / 100, received: (f.receivedCents || 0) / 100, balanceDue: (f.balanceDueCents || 0) / 100 }, msg: res.replayed ? `That payment (${res.receiptId}) is already recorded — not recorded twice.` : (res.message || "Payment recorded.") }; }, // Void a payment recorded by mistake (unposted cash deal only) — RC21: a SERVER command (POST /api/deals/receipt/void, the office). // The original payment stays in the history; a void row (who / when / why) is added and the deal's figures follow. async voidDealReceipt(dealId, receiptId, reason) { if (!api.can("dealPost")) return { ok: false, denied: true, msg: `Correcting a customer payment is done by the office (Admin / F&I, Office Manager or Accounting) — your role (${currentRole() || "this login"}) cannot.` }; const d = (state.deals || []).find(x => x.id === dealId); if (!d) return { ok: false, msg: "Deal not found." }; if (d.postedToGL) return { ok: false, msg: `${d.id} is posted — a posted payment is corrected in Accounting.` }; const list = Array.isArray(d.receipts) ? d.receipts : []; const one = list.find(x => x && x.id === receiptId); if (!one) return { ok: false, msg: "Payment not found on this deal." }; if (one.void) return { ok: true, duplicate: true, rev: d.rev, msg: "That payment is already voided." }; const why = String(reason || "").trim(); if (!why) return { ok: false, msg: "Say why the payment is being voided (kept on the deal)." }; if (!AUTH_TOKEN || __offlineByChoice) return { ok: false, msg: "Voiding a payment needs the server. Connect to the server, then void it." }; const saved = await api._fopsSaveFirst("deals", dealId); if (!saved.ok) return { ok: false, msg: saved.msg }; const key = api._newIdemKey("rv"); const c = await api._command("/deals/receipt/void", { dealId, receiptId, reason: why.slice(0, 200), baseRev: saved.rev }, key, "/deals/op/"); if (!c.ok) { const e = c.err; const b = (e && e.body) || {}; if (c.unknown) return { ok: false, unknown: true, key, msg: `The server did not answer — look at the deal's payments before voiding again (the office can look up ${key}).` }; if (b.record && b.rev != null) api._applyServerRecords([{ collection: "deals", id: dealId, rev: b.rev, record: b.record }]); notify(); return { ok: false, key, code: b.code, msg: b.error || (e && e.message) || "The server did not void the payment." }; } api._applyServerRecords(c.res.records); const nd = (state.deals || []).find(x => x.id === dealId) || d; logAudit("Cash Receipt Voided", `${dealId} — ${receiptId} — ${why.slice(0, 120)}`); persist(); notify(); return { ok: true, rev: nd.rev, msg: c.res.message || "Payment voided." }; }, // ════════════════════════════════════════════════════════════════ // CASCADE: Vehicle stock-in (STKIN) // Adds the unit to live inventory AND posts the purchase entry: // DR inventory (231/237/240/241) · CR floorplan (310/314) · CR cash // for any portion not floored. jrn 70 = NEW VEHICLE PURCHASE, // jrn 71 = USED VEHICLE PURCHASE (matches Doc Entry doc types). // ════════════════════════════════════════════════════════════════ // RC33 (mpx): a SERVER command now — MoneyCore.jsx.txt (loaded after this file) replaces it; this stub only answers when that file is missing stockInVehicle(u) { return moneyMissing("Stock in"); }, // Mark a deal funded — RC21 (deals): a SERVER command (POST /api/deals/fund, the office). A financed / leased deal: the lender // paid what is still in contracts in transit for it (the server books cash 202 / CIT 205 and marks it Funded); a CASH deal: closes // it (paid in full when it posted — no journal entry). Partial / short funding: fundDealAmount (Funding › Reconcile & Fund). async fundDeal(dealId, opts) { opts = opts || {}; if (!api.can("dealPost")) return { ok: false, denied: true, msg: `Funding a deal books journal entries, which your role (${currentRole() || "this login"}) cannot write — ask the office (Admin / F&I, Office Manager or Accounting) to post and fund it.` }; const deal = (state.deals || []).find(d => d.id === dealId); if (!deal) return { ok: false, msg: "Deal not found." }; if (!deal.postedToGL) return { ok: false, msg: "Post the deal to the GL first." }; if (/^funded/i.test(String(deal.status || "")) || deal.stage === "Funded") return { ok: false, msg: "Already funded." }; const open = api.dealCitOpen(deal); return api._fundCommand(deal, open > 0 ? { amount: (open / 100).toFixed(2) } : {}, opts); }, // What is still in contracts in transit for a posted deal, in cents (the posted snapshot's CIT − what fundings cleared). dealCitOpen(d) { if (!d) return 0; const C0 = window.DealCalc; const cents = (v) => (C0 ? C0.toCents(v) : Math.round((+v || 0) * 100)) || 0; let cit; if (d.posted && d.posted.figures && isFinite(+d.posted.figures.citCents)) cit = +d.posted.figures.citCents; else if (/lease/i.test(String(d.dealType || d.mode || ""))) cit = Math.max(0, cents(d.leaseAssigned)); else cit = Math.max(0, cents(d.amtFinanced)); const cleared = (Array.isArray(d.fundings) ? d.fundings : []).reduce((s, f) => s + (f && f.cleared != null ? cents(f.cleared) : 0), 0); return Math.max(0, cit - cleared); }, async _fundCommand(deal, body, opts) { opts = opts || {}; if (!AUTH_TOKEN || __offlineByChoice) return { ok: false, msg: "Funding books the lender's money on the server. Connect to the server, then fund it." }; const t0 = Date.now(); const saved = await api._fopsSaveFirst("deals", deal.id); if (!saved.ok) return { ok: false, msg: saved.msg }; { const refused = api._dealRefusedSince(deal.id, t0); if (refused) return { ok: false, code: "save_refused", msg: refused + " Nothing was funded." }; } const key = opts.idemKey || api._newIdemKey("df"); const r = await api._command("/deals/fund", Object.assign({ dealId: deal.id, baseRev: saved.rev }, body || {}), key, "/deals/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 funding ${deal.id} again, look at it on the Funding screen — the office can look up ${key}.` }; if (b.record && b.rev != null) api._applyServerRecords([{ collection: "deals", id: deal.id, rev: b.rev, record: b.record }]); notify(); return { ok: false, key, code: b.code, msg: b.error || (e && e.message) || "The server did not record the funding." }; } api._applyServerRecords(r.res.records); logAudit("Deal Funded", `${deal.id} — ${r.res.message || ""}${r.recovered ? " (answer recovered after a dropped connection)" : ""}`); persist(); notify(); return { ok: true, key, funded: !!r.res.funded, openAfter: (r.res.openAfterCents || 0) / 100, journalId: r.res.journalId, duplicate: !!r.res.duplicate, msg: r.res.message || `${deal.id} funded.` }; }, // ════════════════════════════════════════════════════════════════ // FUNDING WORKFLOW — expected vs. actual. Books the cash actually // received, clears the full CIT, and CODES the difference (bank fee, // contract error, product cancellation, holdback, short fund) to the // right account. CIT cannot clear without coding the gap. // ════════════════════════════════════════════════════════════════ FUND_DIFF_ACCTS: { "Bank fee": { acct: "604", label: "Bank / finance fees (expense)" }, "Contract error": { acct: "606", label: "Contract error / adjustment" }, "Product cancellation":{ acct: "302", label: "F&I product payable reversal" }, "Holdback": { acct: "232", label: "Lender holdback receivable" }, "Short fund": { acct: "606", label: "Short fund / write-off" }, }, setFundingStatus(dealId, status) { const d = state.deals.find(x => x.id === dealId); if (!d) return { ok: false, msg: "Deal not found." }; // RC21 (deals): "Funded Full / Short" comes from the lender's money recorded on the server (Reconcile & Fund), never a click if (/^funded/i.test(String(status || ""))) return { ok: false, msg: `Use Reconcile & Fund — "${status}" is set when the lender's money is recorded.` }; if (/^funded/i.test(String(d.fundingStatus || "")) || /^funded/i.test(String(d.status || ""))) return { ok: false, msg: `${dealId} is funded — its funding status stays.` }; d.fundingStatus = status; logAudit("Funding Status", `${dealId} → ${status}`); persist(); notify(); return { ok: true, msg: `${dealId} funding: ${status}.` }; }, setFundingChecklist(dealId, patch) { const d = state.deals.find(x => x.id === dealId); if (!d) return { ok: false, msg: "Deal not found." }; d.fundingCheck = { ...(d.fundingCheck || {}), ...patch }; persist(); notify(); return { ok: true }; }, // ══════════════════════════════════════════════════════════════ // POLICY / GOODWILL CONTROL — after-sale repairs the store eats must // attach to a stock #, deal, reason, and MANAGER approval. Approving // posts to policy expense (or used-vehicle policy adjustment) vs. the // service/parts sale. Nothing posts until a manager approves. // ══════════════════════════════════════════════════════════════ POLICY_REASONS: ["Comeback / repeat repair", "Customer satisfaction", "Missed at recon", "Sold AS-IS — goodwill", "Warranty gap", "Sales promise / We-Owe", "Other"], policyAdjustments: () => state.policyAdjustments || [], addPolicyAdjustment(rec) { state.policyAdjustments = state.policyAdjustments || []; const id = "PG-" + String(Date.now()).slice(-6); const p = { id, stock: (rec.stock || "").toUpperCase(), deal: rec.deal || "", customer: rec.customer || "", ro: rec.ro || "", amount: +(+rec.amount || 0).toFixed(2), reason: rec.reason || "Other", complaint: rec.complaint || "", tech: rec.tech || "", salesperson: rec.salesperson || "", source: rec.source || "", // vehicle source (auction/trade) weOwe: !!rec.weOwe, within7: !!rec.within7, soldAsIs: !!rec.soldAsIs, tieToUnit: !!rec.tieToUnit, // capitalize into used-veh cost vs expense status: "Pending", requestedBy: rec.requestedBy || (state.session && state.session.user) || "", created: _iso(new Date()), }; if (!p.amount) return { ok: false, msg: "Policy amount is required." }; state.policyAdjustments.unshift(p); logAudit("Policy Requested", `${id} — ${p.stock || p.customer} $${p.amount.toFixed(2)} (${p.reason})`); persist(); notify(); return { ok: true, id, msg: `Policy ${id} submitted for approval — $${p.amount.toLocaleString()}.` }; }, // RC24 (svcm): APPROVE = one server command (POST /api/fixedops/policy). The server checks the role (Service Manager / office), the // owner's goodwill limit (above it: refused — the owner may override), posts DR 0675 policy work (5015 when tied to the unit) / CR 460A // and marks the request Approved — all or nothing. Nothing changes on this station until the server says "committed". async approvePolicyAdjustment(id, approver) { const p = (state.policyAdjustments || []).find(x => x.id === id); if (!p) return { ok: false, msg: "Policy item not found." }; if (p.status !== "Pending") return { ok: false, msg: `Already ${p.status.toLowerCase()}.` }; if (!api.fopsCanPolicy()) return { ok: false, denied: true, msg: `Your role (${currentRole() || "no role"}) can't approve policy / goodwill — the Service Manager or the office can.` }; if (!AUTH_TOKEN || __offlineByChoice) return { ok: false, msg: "Approving a policy adjustment posts it to the books, which needs the server. Connect to the server, then approve it." }; const saved = await api._fopsSaveFirst("policyAdjustments", id); if (!saved.ok) return { ok: false, msg: saved.msg }; const key = api._newIdemKey("pg"); const r = await api._command("/fixedops/policy", { policyId: id, account: p.tieToUnit ? "5015" : "0675" }, 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 approving ${id} again, refresh the Policy screen — if it shows Approved it was posted. (The office can look up ${key}.)` }; if (b.record && b.rev != null) api._applyServerRecords([{ collection: "policyAdjustments", id, rev: b.rev, record: b.record }]); notify(); return { ok: false, key, code: b.code, msg: b.error || (e && e.message) || "The server refused to approve this policy adjustment.", limitCents: b.limitCents }; } api._applyServerRecords(r.res.records); logAudit("Policy Approved", `${id} — $${((r.res.amountCents || 0) / 100).toFixed(2)} to acct ${r.res.account} → ${r.res.jeId}${r.recovered ? " (answer recovered after a dropped connection)" : ""}`); persist(); notify(); return { ok: true, key, jeId: r.res.jeId, account: r.res.account, msg: r.res.message || `Policy ${id} approved — ${r.res.jeId} posted.` }; }, // RC24 (svcm): what this login may do with policy / goodwill (the server decides; this only shows the button) — routes/svcm.js mayPolicy // (the page canonicalises "Service Manager" to "Service Advisor" — normRole — so the RAW role comes from the sign-in token first) fopsCanPolicy() { const ti = decodeTokenInfo(); const raw = String((ti && !ti.expired && ti.role) || (currentUser && currentUser.role) || "").trim(); return api.fopsIsOffice() || /^service\s*(manager|mgr)$/i.test(raw); }, // the owner's service settings (the goodwill limit) and what this login may do — GET /api/fixedops/svcm/config (cached a minute) async svcmConfig(force) { if (!force && api._svcmCfg && Date.now() - api._svcmCfg.at < 60000) return api._svcmCfg.data; try { const d = await apiGet("/fixedops/svcm/config"); api._svcmCfg = { at: Date.now(), data: d }; return d; } catch (e) { return (api._svcmCfg && api._svcmCfg.data) || { ok: false, msg: (e && e.body && e.body.error) || (e && e.message) || "Could not load the settings." }; } }, denyPolicyAdjustment(id, approver) { const p = (state.policyAdjustments || []).find(x => x.id === id); if (!p) return { ok: false, msg: "Policy item not found." }; p.status = "Denied"; p.approvedBy = approver || "Manager"; p.approvedDate = _iso(new Date()); logAudit("Policy Denied", `${id} — ${p.reason}`); persist(); notify(); return { ok: true, msg: `Policy ${id} denied.` }; }, // ══════════════════════════════════════════════════════════════ // DEAD MONEY — cash trapped across the store: aged CIT, unpaid warranty // & factory receivables, unfunded finance reserve, aged units, trade // payoffs not sent, cores not returned, sublet not billed, obsolete parts. // ══════════════════════════════════════════════════════════════ deadMoney() { const cats = []; const push = (key, label, items, total) => cats.push({ key, label, count: items.length, total: +(+total).toFixed(2), items }); // Aged CIT — delivered, posted, not funded, 7+ days. const agedCit = (state.deals || []).filter(d => d.status === "Delivered Not Funded" && d.postedToGL && daysSince(d.delivered) >= 7) .map(d => ({ ref: d.id, label: d.buyer + " · " + (d.lender || ""), amount: +d.amtFinanced || 0, age: daysSince(d.delivered) })); push("cit", "Aged CIT (delivered, unfunded 7d+)", agedCit, agedCit.reduce((s, x) => s + x.amount, 0)); // Warranty receivable submitted & unpaid. const warr = (state.warrantyReceivables || []).filter(w => w.submitted && !w.paid && w.status !== "Debited") .map(w => ({ ref: w.claim || w.ro, label: w.ro || "", amount: wOpenOf(w), age: daysSince(w.submittedDate) })); push("warranty", "Warranty receivable (submitted, unpaid)", warr, warr.reduce((s, x) => s + x.amount, 0)); // Factory receivable unpaid. const fac = (state.factoryReceivables || []).filter(f => !f.paid && (+f.claimed || +f.amount || 0) > (+f.received || 0)) .map(f => ({ ref: f.deal || f.vin || "", label: f.program || "Factory", amount: (+f.claimed || +f.amount || 0) - (+f.received || 0), age: f.age || 0 })); push("factory", "Factory receivable (unpaid)", fac, fac.reduce((s, x) => s + x.amount, 0)); // Trade payoffs not sent. const payoffs = (state.deals || []).filter(d => (+d.tradePayoff || 0) > 0 && !d.payoffSent) .map(d => ({ ref: d.id, label: d.buyer + " trade", amount: +d.tradePayoff || 0, age: daysSince(d.created) })); push("payoffs", "Trade payoffs not sent", payoffs, payoffs.reduce((s, x) => s + x.amount, 0)); // Aged units — used, 60+ days in stock. const agedUnits = (state.inventory || []).filter(u => !/new/i.test(u.type || "") && window.DealCalc.unitOnLot(u) && (+u.age || daysSince(u.stockedDate) || 0) >= 60) // RC28 fix round: the ONE on-lot reading .map(u => ({ ref: u.stock, label: `${u.yr} ${u.mk} ${u.mdl}`, amount: +u.cost || 0, age: +u.age || daysSince(u.stockedDate) || 0 })); push("agedUnits", "Aged units (60+ days)", agedUnits, agedUnits.reduce((s, x) => s + x.amount, 0)); // Cores not returned / credited. const cores = (state.cores || []).filter(c => !/credited|returned|settled/i.test(c.status || "")) .map(c => ({ ref: c.part, label: (c.desc || "") + " · " + (c.ro || ""), amount: +c.amount || 0, age: daysSince(c.created) })); push("cores", "Cores not returned", cores, cores.reduce((s, x) => s + x.amount, 0)); // Sublet not billed (body shop closed but sublet sale not invoiced is rare; // here: open body ROs with sublet). const sublet = (state.bodyShopROs || []).filter(b => b.status !== "Closed" && (+b.subletSale || 0) > 0) .map(b => ({ ref: b.id, label: b.customer || "", amount: +b.subletSale || 0, age: daysSince(b.created) })); push("sublet", "Sublet not billed (open body ROs)", sublet, sublet.reduce((s, x) => s + x.amount, 0)); // Obsolete / dead parts — no sale in 365d, or zero movement with on-hand. const obs = (state.parts || []).filter(p => (+p.onHand || 0) > 0 && (p.lastSale ? daysSince(p.lastSale) >= 365 : (p.age || 0) >= 365)) .map(p => ({ ref: p.part, label: p.desc || "", amount: (+p.onHand || 0) * (+p.cost || 0), age: p.lastSale ? daysSince(p.lastSale) : (p.age || 0) })); push("obsolete", "Obsolete parts (no sale 12mo)", obs, obs.reduce((s, x) => s + x.amount, 0)); const grand = cats.reduce((s, c) => s + c.total, 0); return { categories: cats, total: +grand.toFixed(2) }; }, // Funding › Reconcile & Fund — RC21 (deals): a SERVER command. `actualReceived` = what the lender paid. A difference with a reason // (FUND_DIFF_ACCTS) is coded to that account and the CIT clears (Funded Short); with NO reason a smaller amount is a PARTIAL funding // (the rest stays in transit, the deal stays unfunded). More than is in transit is refused (an overpayment is not booked here). async fundDealAmount(dealId, actualReceived, diffReason, opts) { if (!api.can("dealPost")) return { ok: false, denied: true, msg: `Funding a deal books journal entries, which your role (${currentRole() || "this login"}) cannot write — ask the office (Admin / F&I, Office Manager or Accounting) to post and fund it.` }; const deal = (state.deals || []).find(d => d.id === dealId); if (!deal) return { ok: false, msg: "Deal not found." }; if (!deal.postedToGL) return { ok: false, msg: "Post the deal to the GL first." }; if (/^funded/i.test(String(deal.status || "")) || deal.stage === "Funded") return { ok: false, msg: "Already funded." }; const amt = +(+actualReceived || 0).toFixed(2); if (!(amt > 0)) return { ok: false, msg: "Enter the amount the lender paid." }; const open = api.dealCitOpen(deal) / 100; const diff = +(open - amt).toFixed(2); const hold = !!(opts && opts.overpay === "hold"); // RC33 (mpx): an over-funding HELD for a refund to the lender (Funding › Lender refund) if (diff < -0.005 && !hold) return { ok: false, msg: `The lender paid $${api._m2(amt)}, which is $${api._m2(-diff)} more than the $${api._m2(open)} still in transit. Check the amount — or, if the lender really over-funded, hold the extra for a refund to the lender.` }; const body = { amount: amt.toFixed(2) }; if (hold && diff < -0.005) body.overpay = "hold"; if (diff > 0.005 && diffReason) body.diffReason = diffReason; if (opts && opts.ref) body.ref = String(opts.ref).slice(0, 60); if (opts && opts.date) body.date = opts.date; return api._fundCommand(deal, body, opts); }, // RC33 (mpx): a SERVER command now — MoneyCore.jsx.txt (loaded after this file) replaces it; this stub only answers when that file is missing payoffFloorplan(stock) { return moneyMissing("A floorplan payoff"); }, // ════════════════════════════════════════════════════════════════ // CASCADE: Close a repair order // Books labor + parts sale & cost, relieves parts inventory, // flags tech hours (payroll), creates customer A/R or warranty // receivable, and posts the GL entry. // ════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════ // RO SPLIT-PAY POSTING (per-job pay types + recorded payments) // An RO that carries `payments[]` (the split-pay RO screen always // creates it) posts ONE close entry ST- built here: // SALES CR labor by job pay type Customer/Courtesy 460A · Warranty 462 // Service Contract 460B · Internal 463 // CR parts on RO 467 · CR sales tax 324A (customer-pay jobs only) // COST DR cost of labor 660A / CR labor applied 999 (hrs × job tech rate) // DR cost of parts 667 / CR parts inventory 242 // PAYERS DR one line per live payment (the receivable side, split by payer): // cash 201 · card 202 (amount + card fee) · invoice A/R 220 // warranty 263 · service contract 264 · courtesy 0675 // ("Courtesy — service dept gross") · internal → unit inventory 240/241/231 // FEE CR card fee income 905E (the fee the customer paid; processor // cost is NOT this fee — it comes in on the processor statement) // Payments recorded after close post their own PMT- entry against A/R 220; // voids/refunds post reversing entries — nothing is ever deleted. // Every entry carries `ro: `. // ════════════════════════════════════════════════════════════════ roDeclinedJobIds(ro) { return new Set(((ro && ro.ops) || []).filter(o => o.approval === "declined").map(o => String(o.id))); }, // RC32: an RO for a look-only read (the RO itself, by id, or the open preview's "RO-PREVIEW…") _roForRead(roOrId) { if (roOrId && typeof roOrId === "object") return roOrId; const ro = (state.repairOrders || []).find(r => r.id === roOrId); if (ro) return ro; return typeof roOrId === "string" && /^RO-PREVIEW/.test(roOrId) ? (_svcPreviewROs.get(roOrId) || null) : null; }, // RC32: svcPreviewRO(ro) while Setup's preview is open; svcPreviewRO(null) forgets it svcPreviewRO(ro) { if (!ro) { _svcPreviewROs.clear(); return true; } const id = String(ro.id || ""); if (!/^RO-PREVIEW/.test(id) || (state.repairOrders || []).some(r => r.id === id)) return false; _svcPreviewROs.set(id, ro); return true; }, RO_PAY_METHODS: ["cash", "card", "invoice", "warranty", "contract", "courtesy", "internal", "sunbit"], // RC28 (svc): + sunbit (taken only with Close RO / after the close — the server records it) roLaborAcct(payType) { return payType === "Warranty" ? ACCT.warrLabor : payType === "Internal" ? ACCT.internalLabor : payType === "Service Contract" ? ACCT.contractLabor : ACCT.custLabor; }, roPayAcct(p, ro) { const m = p && p.method; if (m === "cash") return ACCT.cashOnHand; if (m === "card") return ACCT.cardDeposit; if (m === "invoice") return ACCT.arCustomer; if (m === "warranty") return ACCT.warrantyRec; if (m === "contract") return ACCT.contractRec; if (m === "courtesy") return ACCT.svcCourtesy; if (m === "sunbit") return (p && p.recAcct) || (api._svc && api._svc.config && api._svc.config.settings && api._svc.config.settings.sunbit && api._svc.config.settings.sunbit.recAcct) || "294"; // RC28 (svc): the Sunbit receivable if (m === "internal") { const stock = String(p.stock || (ro && ro.internalStock) || "").toUpperCase(); const u = (state.inventory || []).find(x => String(x.stock || "").toUpperCase() === stock); if (u && u.invAcct) return u.invAcct; if (u && /^n/i.test(String(u.type || ""))) return ACCT.invNewCar; return /truck|silverado|sierra|colorado|tahoe|suburban/i.test(u ? (u.mdl || "") + " " + (u.body || "") : "") ? ACCT.invUsedTruck : ACCT.invUsedCar; } return ACCT.arCustomer; }, roPayLabel(m) { return ({ cash: "Cash", card: "Credit card", invoice: "Invoice (A/R)", warranty: "Warranty", contract: "Service contract", courtesy: "Courtesy — service dept gross", internal: "Internal (charge to unit)", check: "Check", sunbit: "Sunbit (pay over time)" })[m] || m; // RC28 (svc): + sunbit }, roLivePayments(ro) { return (ro && ro.payments || []).filter(p => !p.voided); }, roIsSplitPay(ro) { return !!(ro && Array.isArray(ro.payments)); }, // ════════════════════════════════════════════════════════════════ // RC20 (fops) — RO totals and the close entry come from ONE calculation, window.FopsCalc (FopsCalc.jsx.txt = the server's // src/lib/fixedops-calc.js, byte for byte): integer cents, tax once on the customer-pay taxable work, the shared tax settings // (serviceSettings.tax). The station only PREVIEWS; POST /api/ro/close commits — the server's answer is what shows and prints. // ════════════════════════════════════════════════════════════════ fopsTaxConfig() { return window.FopsCalc ? window.FopsCalc.taxConfig(state.serviceSettings || {}) : null; }, // what this login may do (the server decides; these only hide buttons that would be refused) — routes/fixedops.js role lists fopsSeesShopCost() { const raw = String((currentUser && currentUser.role) || ((decodeTokenInfo() || {}).role) || ""); return api.can("acct") || /^service\s*(manager|mgr)$/i.test(raw.trim()); }, fopsCanCloseRO() { return ["Admin / F&I", "Office Manager", "Accounting", "Service Advisor", "Service"].includes(currentRole()); }, fopsCanSellParts() { return ["Admin / F&I", "Office Manager", "Accounting", "Service Advisor", "Service", "Parts"].includes(currentRole()); }, fopsIsOffice() { return ["Admin / F&I", "Office Manager", "Accounting"].includes(currentRole()); }, _fopsCalcCtx() { const techs = state.technicians || []; return { techRateCents: (id) => { const t = id != null && techs.find(x => x.id === id); const c = t && t.rate != null && t.rate !== "" ? window.FopsCalc.toCents(t.rate) : NaN; return isFinite(c) ? c : null; }, partCostCents: (p) => { const x = (state.parts || []).find(y => y.part === p); const c = x ? window.FopsCalc.toCents(x.cost) : NaN; return isFinite(c) ? c : null; }, }; }, // The RO's money as every screen shows it: the server's committed invoice once closed, else the shared calculation. roMoney(roOrId) { const ro = roOrId && typeof roOrId === "object" ? roOrId : (state.repairOrders || []).find(r => r.id === roOrId); if (!ro) return null; const F = window.FopsCalc; const live = (ro.payments || []).filter(p => p && !p.voided); const paid = live.reduce((a, p) => a + F.toCents(p.amount), 0); if (ro.status === "Closed" && !ro.foInvoice) { // closed before RC20: the figures it was closed with (viewing never re-figures money) const R2c = (n) => F.toCents(n) || 0; const tot = ro.total != null ? R2c(ro.total) : R2c(ro.laborSale) + R2c(ro.partsSale) + R2c(ro.subletSale) + R2c(ro.tax); return { committed: true, stored: true, total: tot / 100, labor: R2c(ro.laborSale) / 100, parts: R2c(ro.partsSale) / 100, sublet: R2c(ro.subletSale) / 100, fees: R2c(ro.feesSale) / 100, tax: R2c(ro.tax) / 100, taxable: R2c(ro.taxBase) / 100, byPayer: {}, customerBalance: Math.max(0, tot - paid) / 100, paid: paid / 100, receivables: [], taxRatePct: null }; } if (ro.foInvoice && ro.status === "Closed") { const v = ro.foInvoice; return { committed: true, total: v.totalCents / 100, labor: v.laborCents / 100, parts: v.partsCents / 100, sublet: v.subletCents / 100, fees: v.feesCents / 100, tax: v.taxCents / 100, taxable: v.taxableCents / 100, byPayer: Object.keys(v.byPayer || {}).reduce((o, k) => { o[k] = v.byPayer[k].total / 100; return o; }, {}), customerBalance: v.customerBalanceCents / 100, paid: paid / 100, receivables: v.receivables || [], invoice: v, taxRatePct: v.taxRatePct }; } const t = F.roTotals(ro, api.fopsTaxConfig(), api._fopsCalcCtx()); const al = F.roAllocate(t, F.closePayments(ro), { warrantyClaim: ro.warrantyClaim || ((ro.payments || []).find(p => p && !p.voided && p.method === "warranty") || {}).claimNo, internalStock: ro.internalStock || ro.stock || ((ro.payments || []).find(p => p && !p.voided && p.method === "internal") || {}).stock }); return { committed: false, total: t.totalCents / 100, labor: t.laborCents / 100, parts: t.partsCents / 100, sublet: t.subletCents / 100, fees: t.feesCents / 100, tax: t.taxCents / 100, taxable: t.taxableCents / 100, byPayer: Object.keys(t.byPayer).reduce((o, k) => { if (t.byPayer[k].total) o[k] = t.byPayer[k].total / 100; return o; }, {}), customerBalance: al.customerBalanceCents / 100, paid: paid / 100, problems: al.problems, receivables: Object.keys(al.unpaid).filter(k => al.unpaid[k] > 0).map(k => ({ payer: k, amountCents: al.unpaid[k] })), totals: t, alloc: al, taxRatePct: t.taxRatePct }; }, // Preview of the close entry (the Accounting tab, FixedOpsGL). The server builds the real one from the same map. // A role without the shop-cost capability (decision 12) sees no cost lines — it never receives technician pay or RO cost. roCloseLines(roId) { const ro = api._roForRead(roId); // RC32: look only if (!ro) return null; const F = window.FopsCalc; const m = api.roMoney(ro); if (m.committed) return { lines: [], allocated: m.paid, total: m.total, balance: m.customerBalance, fees: 0, committed: true }; const unit = ro.internalStock ? (state.inventory || []).find(u => String(u.stock || "").toUpperCase() === String(ro.internalStock).toUpperCase()) : null; const cfg = api.fopsTaxConfig(); let lines = F.roClosePosting(ro, m.totals, m.alloc, F.closePayments(ro), { unitAcct: F.unitInvAcct(unit), feeAcct: cfg.feeAcct, warrantyClaim: ro.warrantyClaim, internalStock: ro.internalStock }); if (!api.fopsSeesShopCost()) { const C0 = F.ACCT; const costA = [C0.costLabor, C0.laborApplied, C0.costPartsRO, C0.invParts, C0.subletCost, C0.subletInv]; lines = lines.filter(l => !(costA.includes(l.acct) && /^(Cost of|Labor applied|Relieve)/.test(l.desc || ""))); } const out = lines.map(l => ({ acct: l.acct, dr: l.dr / 100, cr: l.cr / 100, desc: l.desc })); return { lines: out, allocated: m.paid, total: m.total, balance: m.customerBalance, fees: m.alloc.feesCents / 100, problems: m.problems, preview: true }; }, // Push this record's unsaved changes and wait, so a command acts on exactly what the screen shows. → { ok, rev } | { ok:false, msg } async _fopsSaveFirst(col, id) { const k = recKey(col, id); const pause = (ms) => new Promise(r => setTimeout(r, ms)); const deadline = Date.now() + 8000; if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } for (let i = 0; i < 4 && Date.now() < deadline; i++) { while ((flushing || pendingReFlush) && Date.now() < deadline) await pause(40); const cur = getStateRecord(col, id); if (cur && lastSent.get(k) === JSON.stringify(cur)) break; try { await flushNow(); } catch (e) {} } while ((flushing || pendingReFlush) && Date.now() < deadline) await pause(40); const cur = getStateRecord(col, id); if (!cur) return { ok: false, msg: "This record is not on this station." }; if (lastSent.get(k) !== JSON.stringify(cur)) return { ok: false, msg: `${id} has changes that are not saved on the server yet (${__connState === "online" ? "still sending" : "the server is not reachable"}). Wait until it says Saved, then try again.` }; return { ok: true, rev: lastRev.has(k) ? lastRev.get(k) : 0 }; }, // RC20: CLOSE = one server command (POST /api/ro/close). The server checks the role for THIS action, re-reads the RO, checks the // jobs and the payments, computes the money, relieves the stock, books the receivables and posts the journal — all or nothing. // Nothing changes on this station until the server says "committed"; then the committed RO / invoice replace the local copy. async closeRO(roId, opts) { opts = opts || {}; const ro = (state.repairOrders || []).find(r => r.id === roId); if (!ro) return { ok: false, msg: "RO not found." }; if (ro.status === "Closed") return { ok: false, msg: `${ro.id} is already closed.` }; if (!api.fopsCanCloseRO()) return { ok: false, denied: true, msg: `Your role (${currentRole() || "no role"}) can't close repair orders — a service advisor, the service manager or the office can.` }; if (!AUTH_TOKEN || __offlineByChoice) return { ok: false, msg: "Closing an RO posts it to the books, which needs the server. Connect to the server, then close it." }; const saved = await api._fopsSaveFirst("repairOrders", roId); if (!saved.ok) return { ok: false, msg: saved.msg }; const key = opts.idemKey || api._newIdemKey("ro"); const r = await api._command("/ro/close", { roId, baseRev: saved.rev, payments: Array.isArray(opts.payments) && opts.payments.length ? opts.payments : undefined }, 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 closing ${roId} again, look at it on the board — if it shows Closed it was posted. (The office can look up ${key}.)` }; if (b.record && b.rev != null) api._applyServerRecords([{ collection: "repairOrders", id: roId, rev: b.rev, record: b.record }]); notify(); return { ok: false, key, code: b.code, msg: b.error || (e && e.message) || "The server refused to close this RO." }; } const res = r.res; api._applyServerRecords(res.records); const inv = res.invoice || {}; logAudit("RO Closed", `${roId} → ${res.jeId} · total $${((inv.totalCents || 0) / 100).toFixed(2)} · customer balance $${((inv.customerBalanceCents || 0) / 100).toFixed(2)}${r.recovered ? " (answer recovered after a dropped connection)" : ""}`); persist(); notify(); const warn = Array.isArray(res.warnings) && res.warnings.length ? " ⚠ " + res.warnings.join(" ") : ""; return { ok: true, id: res.jeId, jeId: res.jeId, invoice: inv, records: res.records, warnings: res.warnings || [], key, recovered: !!r.recovered, msg: `${roId} closed and posted (${res.jeId}) — total $${((inv.totalCents || 0) / 100).toFixed(2)}${inv.customerBalanceCents ? ", customer balance $" + (inv.customerBalanceCents / 100).toFixed(2) + " on A/R" : ""}.` + warn }; }, // ════════════════════════════════════════════════════════════ // CASCADE: Route a credit application to lenders (the feed line) // Creates one live submission per lender. Decisions are NOT simulated // (A-11): a submission stays pending until recordLenderDecision() is // called with the lender's real response. // ════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════ // CHECKER: is this unit clean to desk / deliver? // Returns every open item holding the unit: open service ROs (by VIN // or stock), parts committed on those ROs, cores due, and any other // working deal already on the same stock #. // ════════════════════════════════════════════════════════════ unitOpenItems(stockOrVin, opts) { const q = String(stockOrVin || "").toUpperCase().trim(); if (!q) return { clean: true, items: [] }; const exceptDeal = opts && opts.exceptDeal; const unit = state.inventory.find(u => String(u.stock || "").toUpperCase() === q || String(u.vin || "").toUpperCase() === q); const stock = unit ? String(unit.stock).toUpperCase() : q; const vin = unit ? String(unit.vin || "").toUpperCase() : q; const items = []; // Open service ROs touching this unit (VIN match, or stock # referenced // in the customer/vehicle text — how internal recon ROs are written). (state.repairOrders || []).forEach(ro => { if (String(ro.status) !== "Open") return; const roVin = String(ro.vin || "").toUpperCase(); const hay = (String(ro.customer || "") + " " + String(ro.vehicle || "")).toUpperCase(); if ((vin && roVin && roVin === vin) || (stock && hay.includes(stock))) { items.push({ kind: "RO", id: ro.id, severity: "block", label: `Open ${String(ro.payType || "").toLowerCase()} RO ${ro.id}` + (ro.concern ? ` — ${ro.concern}` : "") + ` (labor $${(+ro.laborSale || 0).toFixed(2)}${(+ro.partsSale || 0) ? `, parts $${(+ro.partsSale).toFixed(2)}` : ""})`, }); if ((+ro.partsSale || 0) > 0) items.push({ kind: "PARTS", id: ro.id, severity: "warn", label: `Parts committed on open ticket ${ro.id} — $${(+ro.partsSale).toFixed(2)} (relieve or bill before delivery)` }); const core = (state.cores || []).find(c => c.ro === ro.id && !/credited|returned|settled/i.test(c.status || "")); if (core) items.push({ kind: "CORE", id: core.part, severity: "warn", label: `Core ${core.part} (${core.desc}) ${core.status.toLowerCase()} on ${ro.id} — $${(+core.amount).toFixed(2)}` }); } }); // Another working deal already holds this stock #. (state.deals || []).forEach(d => { if (exceptDeal && d.id === exceptDeal) return; if (/funded|dead|unwound/i.test(d.status || "")) return; if (String(d.stock || "").toUpperCase() === stock) items.push({ kind: "DEAL", id: d.id, severity: "warn", label: `Already on working deal ${d.id} (${d.buyer}) — ${d.status || "Working"}` }); }); // Unit-level flags. if (unit && !window.DealCalc.unitSellable(unit)) // RC28 fix round: the posting's own test (DealCalc.unitSellable) items.push({ kind: "STATUS", id: unit.stock, severity: "block", label: `Unit status is ${unit.status}` }); return { clean: items.length === 0, stock, vin, unit: unit || null, items, blocks: items.filter(i => i.severity === "block").length }; }, // ════════════════════════════════════════════════════════════════ // VEHICLE TRUTH BOX — per-unit retail-readiness gate. New units skip // the used-only checks (payoff/recon). "Can be sold?" = every required // item green AND no open RO/parts blocks from unitOpenItems(). // ════════════════════════════════════════════════════════════════ _unit(stockOrVin) { const q = String(stockOrVin || "").toUpperCase().trim(); return (state.inventory || []).find(u => String(u.stock || "").toUpperCase() === q || String(u.vin || "").toUpperCase() === q) || null; }, // ════════════════════════════════════════════════════════════════ // MODULE EXTENSION POINT — feature modules that load after DMSCore // (ServiceCore, ReconCore, ImportCore …) change shared state ONLY through // _mutate, so every change persists, syncs to the server and notifies // the screens exactly like the built-in functions do. // ════════════════════════════════════════════════════════════════ _mutate(fn) { const r = fn(state, { R2: (n) => +(+n || 0).toFixed(2), iso: _iso, fmtDate: _fmtDate, periodOf: _periodOf }); persist(); notify(); return r; }, _read(fn) { return fn(state); }, _postGL(entry) { postGL({ by: currentUser.user, ...entry }); persist(); notify(); return { ok: true, id: entry.id }; }, _audit(action, detail) { logAudit(action, detail); }, _ACCT: ACCT, _uid(prefix) { return (prefix || "X") + "-" + Date.now().toString(36).toUpperCase() + Math.random().toString(36).slice(2, 5).toUpperCase(); }, stdJobs: () => state.stdJobs || [], serviceSettings: () => state.serviceSettings || {}, importHistory: () => state.importHistory || [], importTemplates: () => state.importTemplates || [], msgConsents: () => state.msgConsents || [], messageLog: () => state.messageLog || [], // ════════════════════════════════════════════════════════════════ // INVENTORY — import (merge by stock #, then VIN), edit with history, // unit ↔ general ledger, explicit cost adjustment. // ════════════════════════════════════════════════════════════════ // Normalize one imported row to the unit schema. Accepts the raw mapped // row from Importers (any of the common column spellings already picked). _normUnitRow(r) { const txt = (v) => (v === undefined || v === null) ? "" : String(v).trim(); const num = (v) => { if (v === undefined || v === null || String(v).trim() === "") return null; const n = parseFloat(String(v).replace(/[$,\s]/g, "")); return isNaN(n) ? NaN : n; }; // 9/15/26 · 09-15-2026 · 2026-09-15 → ISO at noon local; blank → null; not a date → NaN const dte = (v) => { const s0 = txt(v); if (!s0) return null; let m, y, mo, d; if ((m = s0.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/))) { y = +m[1]; mo = +m[2]; d = +m[3]; } else if ((m = s0.match(/^(\d{1,2})[\/\-.](\d{1,2})[\/\-.](\d{2}|\d{4})$/))) { mo = +m[1]; d = +m[2]; y = +m[3]; if (y < 100) y += y < 70 ? 2000 : 1900; } else return NaN; const dt = new Date(y, mo - 1, d, 12); if (dt.getFullYear() !== y || dt.getMonth() !== mo - 1 || dt.getDate() !== d || y < 1900 || y > 2100) return NaN; return _iso(dt); }; const nu = txt(r.type != null && /^(n|new|u|used|cpo|certified|demo)/i.test(txt(r.type)) ? r.type : (r.newUsed != null ? r.newUsed : (r.status && /^(n|new|u|used|cpo|certified)$/i.test(txt(r.status)) ? r.status : r.condition))); let type = !nu ? "" : (/^n/i.test(nu) || /^demo/i.test(nu) ? "New" : "Used"); // Old-DMS inventory accounts: 231 / 237 are NEW car / truck; 240 / 240A / 241 / 241A are USED. const invAcct = txt(r.invAcct).toUpperCase().replace(/\s+/g, ""); let typeFromAcct = false; if (!type && invAcct) { if (/^23[17]$/.test(invAcct)) { type = "New"; typeFromAcct = true; } else if (/^24[01]A?$/.test(invAcct)) { type = "Used"; typeFromAcct = true; } } const invoiceCost = num(r.invoiceCost); const out = { stock: txt(r.stock).toUpperCase(), vin: txt(r.vin).toUpperCase().replace(/\s+/g, ""), yr: txt(r.yr || r.year), mk: txt(r.mk || r.make), mdl: txt(r.mdl || r.model), trim: txt(r.trim), body: txt(r.body || (r.type && !/^(n|new|u|used|cpo|certified|demo)/i.test(txt(r.type)) ? r.type : "")), color: txt(r.color), description: txt(r.description || r.desc), miles: num(r.miles != null ? r.miles : (r.odometer != null ? r.odometer : r.mileage)), // the purchase / invoice cost is the unit cost (repairs are kept apart in recon), so it wins over a "vehicle cost" column cost: invoiceCost != null ? invoiceCost : num(r.cost), asking: num(r.asking != null ? r.asking : (r.price != null ? r.price : (r.retail != null ? r.retail : r.list))), msrp: num(r.msrp), type, typeFromAcct, invAcct, invoiceCost, invoiceDate: dte(r.invoiceDate), recon: num(r.repairs != null ? r.repairs : r.recon), costBooked: num(r.glCost != null ? r.glCost : r.costBooked), age: num(r.age), pack: num(r.pack), _has: (() => { const k = Object.keys(r || {}); const has = (...ns) => ns.some(n => k.includes(n)); return { vin: has("vin"), yr: has("yr", "year"), mk: has("mk", "make"), mdl: has("mdl", "model"), trim: has("trim"), body: has("body") || (has("type") && !type), color: has("color"), description: has("description", "desc"), miles: has("miles", "odometer", "mileage"), cost: has("cost", "invoiceCost"), asking: has("asking", "price", "retail", "list"), msrp: has("msrp"), type: has("type", "newUsed", "condition") || !!type, invAcct: has("invAcct"), invoiceDate: has("invoiceDate"), stockedDate: has("invoiceDate"), invoiceCost: has("invoiceCost"), recon: has("repairs", "recon"), costBooked: has("glCost", "costBooked"), age: has("age"), pack: has("pack") }; })(), }; out.stockedDate = out.invoiceDate; return out; }, // opts: { clearBlanks (blank cells CLEAR the field), dryRun (plan only) } // Inventory import NEVER posts to the general ledger: new units get costBooked = the // file's "GL cost" (reference) or null, and the Accounting tab shows any difference. importInventory(rows, opts) { const o = opts || {}; const plan = [], errors = []; let created = 0, updated = 0, unchanged = 0; const FIELDS = ["vin", "yr", "mk", "mdl", "trim", "body", "color", "description", "miles", "cost", "asking", "msrp", "type", "invAcct", "invoiceDate", "stockedDate", "invoiceCost", "recon", "costBooked", "age", "pack"]; const numeric = { miles: 1, cost: 1, asking: 1, msrp: 1, invoiceCost: 1, recon: 1, costBooked: 1, age: 1, pack: 1 }; const dateF = { invoiceDate: 1, stockedDate: 1 }; const RAWKEY = { recon: "repairs", costBooked: "glCost" }; const seen = new Set(); (rows || []).forEach((raw, i) => { const rowNo = i + 2; // header is row 1 const r = api._normUnitRow(raw || {}); if (!r.stock && !r.vin) { errors.push({ row: rowNo, field: "stock/VIN", reason: "no stock # or VIN — row skipped" }); return; } const key = r.stock || r.vin; if (seen.has(key)) { errors.push({ row: rowNo, field: "stock", reason: `duplicate of an earlier row (${key}) — skipped` }); return; } seen.add(key); FIELDS.forEach(f => { if (numeric[f] && Number.isNaN(r[f])) { errors.push({ row: rowNo, field: RAWKEY[f] || f, reason: `"${(raw || {})[RAWKEY[f] || f] ?? ""}" is not a number — field left unchanged` }); r[f] = null; } if (dateF[f] && typeof r[f] === "number") { errors.push({ row: rowNo, field: "invoiceDate", reason: `"${(raw || {}).invoiceDate ?? ""}" is not a date — field left unchanged` }); r[f] = null; } }); if (r.vin && r.vin.length !== 17) errors.push({ row: rowNo, field: "vin", reason: `VIN "${r.vin}" is ${r.vin.length} characters (expected 17) — imported as given` }); // Established rule: stock # identifies the unit; VIN is the fallback. let u = r.stock ? (state.inventory || []).find(x => String(x.stock).toUpperCase() === r.stock) : null; if (!u && r.vin) u = (state.inventory || []).find(x => String(x.vin || "").toUpperCase() === r.vin) || null; if (u && r.stock && r.vin && u.vin && String(u.vin).toUpperCase() !== r.vin && String(u.stock).toUpperCase() === r.stock) { errors.push({ row: rowNo, field: "vin", reason: `stock ${u.stock} is already ${u.vin} — VIN ${r.vin} NOT applied (check the file)` }); r.vin = null; r._has.vin = false; } if (!u) { if (!r.stock) { errors.push({ row: rowNo, field: "stock", reason: `no stock # for new VIN ${r.vin} — row skipped` }); return; } const isNew = r.type === "New"; // car or truck: from the old inventory account when the file has one (237 / 241 / 241A = truck, // 231 / 240 / 240A = car), otherwise from the model / body text const isTruck = /^(237|241A?)$/.test(r.invAcct) ? true : /^(231|240A?)$/.test(r.invAcct) ? false : /truck|pickup|silverado|sierra|colorado|1500|2500|3500/i.test(r.mdl + " " + r.body); const acct = isNew ? (isTruck ? { sale: ACCT.saleNewTruck, cost: ACCT.costNewTruck, inv: ACCT.invNewTruck } : { sale: ACCT.saleNewCar, cost: ACCT.costNewCar, inv: ACCT.invNewCar }) : (isTruck ? { sale: ACCT.saleUsedTruck, cost: ACCT.costUsedTruck, inv: ACCT.invUsedTruck } : { sale: ACCT.saleUsedCar, cost: ACCT.costUsedCar, inv: ACCT.invUsedCar }); const unit = { stock: r.stock, vin: r.vin, yr: r.yr, mk: r.mk, mdl: r.mdl, trim: r.trim, body: r.body, color: r.color, description: r.description, miles: r.miles || 0, cost: r.cost || 0, asking: r.asking || 0, msrp: r.msrp || 0, type: r.type || "Used", age: r.age || 0, status: "In Stock", stockedDate: r.stockedDate || _iso(new Date()), imported: true, costBooked: r.costBooked != null ? r.costBooked : null, saleAcct: acct.sale, costAcct: acct.cost, invAcct: r.invAcct || acct.inv, floorAcct: isNew ? ACCT.floorNewGM : ACCT.floorUsed, ...(r.invAcct ? {} : { invAcctFrom: "guess" }) }; // RC25 (imp) fix round (audit M1): the file had no inventory account — this car / truck account is a guess; Reconcile inventory to GL counts the unit "without an account" (the schedule total ties, never a guessed split) if (r.invoiceCost != null) unit.invoiceCost = r.invoiceCost; if (r.invoiceDate) unit.invoiceDate = r.invoiceDate; if (r.recon != null) unit.recon = r.recon; if (r.pack != null) unit.pack = r.pack; if (!r.type) errors.push({ row: rowNo, field: "type", reason: "new/used blank — saved as Used; set it on the unit" }); plan.push({ row: rowNo, action: "create", stock: r.stock, fields: unit }); if (!o.dryRun) { state.inventory.unshift(unit); created++; } return; } const changes = {}; const same = (a, b, isNum, isDate) => isNum ? (+(a || 0) === +(b || 0)) : isDate ? String(a == null ? "" : a).slice(0, 10) === String(b == null ? "" : b).slice(0, 10) : String(a == null ? "" : a).trim() === String(b == null ? "" : b).trim(); FIELDS.forEach(f => { if (!r._has || !r._has[f]) return; // the file has no column for it → never touched const v = r[f]; const blank = v === null || v === "" || v === undefined; if (blank && !o.clearBlanks) return; // blank cell never wipes existing data if (f === "type" && blank) return; // new/used is never cleared to nothing if (f === "type" && r.typeFromAcct && u.type) return; // a type inferred from the inventory account never overrides a unit that already has one if (f === "costBooked" && blank) return; // the booked GL cost is only ever set, never cleared by a blank const next = blank ? (numeric[f] ? 0 : "") : v; if (f === "type" && /^new/i.test(String(u.type || "")) && next === "New") return; // "New Truck"/"New Car" already new if (!same(next, u[f], numeric[f], dateF[f])) changes[f] = { from: u[f], to: next }; }); if (!Object.keys(changes).length) { unchanged++; plan.push({ row: rowNo, action: "unchanged", stock: u.stock }); return; } plan.push({ row: rowNo, action: "update", stock: u.stock, changes }); if (!o.dryRun) { Object.keys(changes).forEach(f => { u[f] = changes[f].to; }); if (changes.invAcct && u.invAcctFrom) delete u.invAcctFrom; // RC25 (imp): an account given by the file is no longer a guess u.edits = [...(u.edits || []), { at: _iso(new Date()), by: currentUser.user, source: "import", changes }]; updated++; } }); if (!o.dryRun) { logAudit("Inventory Import", `${created} created · ${updated} updated · ${unchanged} unchanged · ${errors.length} issue(s)${o.clearBlanks ? " · blanks CLEARED fields" : ""}`); persist(); notify(); } return { ok: true, dryRun: !!o.dryRun, created: o.dryRun ? plan.filter(p => p.action === "create").length : created, updated: o.dryRun ? plan.filter(p => p.action === "update").length : updated, unchanged, errors, plan }; }, // GL accounts a unit posts to, with names, + every entry that touched it. acctName(acct) { const a = String(acct || ""); const g = (state.glAccounts || []).find(x => String(x.acct || x.account || x.no) === a); if (g) return g.name || g.desc || g.description || ""; const key = Object.keys(ACCT).find(k => ACCT[k] === a); return key && ACCT_META[key] ? ACCT_META[key].label : ""; }, unitGL(stockOrVin) { const u = api._unit(stockOrVin); if (!u) return { ok: false, msg: "Unit not found." }; const isNew = /^n/i.test(String(u.type || "")); const accts = [ { role: "Inventory (asset)", acct: u.invAcct || (isNew ? ACCT.invNewCar : ACCT.invUsedCar) }, { role: "Sale (income)", acct: u.saleAcct || (isNew ? ACCT.saleNewCar : ACCT.saleUsedCar) }, { role: "Cost of sale", acct: u.costAcct || (isNew ? ACCT.costNewCar : ACCT.costUsedCar) }, { role: "Floorplan", acct: u.floorAcct || (isNew ? ACCT.floorNewGM : ACCT.floorUsed) }, ].map(a => ({ ...a, name: api.acctName(a.acct) })); const stock = String(u.stock).toUpperCase(), vin = String(u.vin || "").toUpperCase(); const dealIds = new Set((state.deals || []).filter(d => String(d.stock || "").toUpperCase() === stock).map(d => d.id)); const mentions = (t) => { const x = String(t || "").toUpperCase(); return (stock && new RegExp("\\b" + stock.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&") + "\\b").test(x)) || (vin && x.includes(vin)); }; const entries = (state.journalEntries || []).filter(j => String(j.stock || "").toUpperCase() === stock || (j.dealId && dealIds.has(j.dealId)) || mentions(j.desc) || (j.lines || []).some(l => mentions(l.desc))); const acctSet = new Set(accts.map(a => a.acct)); const invBal = +entries.reduce((s2, j) => s2 + (j.lines || []).filter(l => l.acct === accts[0].acct).reduce((x, l) => x + (+l.dr || 0) - (+l.cr || 0), 0), 0).toFixed(2); return { ok: true, unit: u, accounts: accts, entries, inventoryBalance: invBal, costBooked: u.costBooked != null ? u.costBooked : null, // An imported unit's booked cost is the OLD ledger's cost, which already includes repairs — compare it with purchase + repairs. costDiff: u.costBooked != null ? +((+u.cost || 0) + (u.imported ? (+u.recon || 0) : 0) - (+u.costBooked || 0)).toFixed(2) : 0, touchesAccount: (a) => acctSet.has(a) }; }, // Edit a unit's details. Never re-posts accounting: a cost change on a // unit whose purchase is already booked leaves the GL alone and reports // the difference, which needs postUnitCostAdjustment (explicit, audited). editUnit(stockOrVin, patch, reason) { const u = api._unit(stockOrVin); if (!u) return { ok: false, msg: "Unit not found: " + stockOrVin }; const p = patch || {}; const ALLOWED = ["vin", "yr", "mk", "mdl", "trim", "body", "color", "description", "miles", "cost", "asking", "msrp", "type", "pack", "recon", "status", "lot", "keyTag", "notes", "options", "extColor", "intColor", "engine", "trans", "drive", "fuel"]; const numeric = { miles: 1, cost: 1, asking: 1, msrp: 1, pack: 1, recon: 1 }; const changes = {}; ALLOWED.forEach(k => { if (p[k] === undefined) return; let v = p[k]; if (numeric[k]) { v = +String(v).replace(/[$,\s]/g, "") || 0; } if (k === "vin") v = String(v || "").toUpperCase().trim(); if (k === "type") v = /^n/i.test(String(v)) ? (/truck/i.test(String(u.type)) ? "New Truck" : "New") : "Used"; if (JSON.stringify(v) !== JSON.stringify(u[k])) changes[k] = { from: u[k], to: v }; }); if (!Object.keys(changes).length) return { ok: false, msg: "Nothing changed." }; if (changes.vin && changes.vin.to && (state.inventory || []).some(x => x !== u && String(x.vin || "").toUpperCase() === changes.vin.to)) return { ok: false, msg: `VIN ${changes.vin.to} is already on another unit.` }; const sold = window.DealCalc.unitGone(u); // RC28 fix round: the ONE on-lot reading const hasPostings = !!u.purchaseJe || sold || (state.deals || []).some(d => d.postedToGL && String(d.stock) === String(u.stock)); const notes = []; Object.keys(changes).forEach(k => { u[k] = changes[k].to; }); if (changes.type) { if (hasPostings) notes.push("New/used changed — GL accounts on this unit were NOT switched because it already has postings; reclass with a journal entry if needed."); else { const isNew = /^n/i.test(String(u.type)); const isTruck = /truck|pickup|silverado|sierra|colorado|1500|2500|3500/i.test((u.mdl || "") + " " + (u.body || "")); const a = isNew ? (isTruck ? { sale: ACCT.saleNewTruck, cost: ACCT.costNewTruck, inv: ACCT.invNewTruck } : { sale: ACCT.saleNewCar, cost: ACCT.costNewCar, inv: ACCT.invNewCar }) : (isTruck ? { sale: ACCT.saleUsedTruck, cost: ACCT.costUsedTruck, inv: ACCT.invUsedTruck } : { sale: ACCT.saleUsedCar, cost: ACCT.costUsedCar, inv: ACCT.invUsedCar }); u.saleAcct = a.sale; u.costAcct = a.cost; u.invAcct = a.inv; u.floorAcct = isNew ? ACCT.floorNewGM : ACCT.floorUsed; } } if (changes.cost && u.costBooked != null && (+u.cost + (u.imported ? (+u.recon || 0) : 0)) !== +u.costBooked) notes.push(`Cost now $${(+u.cost).toLocaleString()} but $${(+u.costBooked).toLocaleString()} is booked in the GL — post a cost adjustment to correct the books.`); u.edits = [...(u.edits || []), { at: _iso(new Date()), by: currentUser.user, reason: reason || "", changes }]; logAudit("Unit Edited", `${u.stock} · ${Object.keys(changes).map(k => `${k}: ${changes[k].from ?? "—"} → ${changes[k].to}`).join("; ")}${reason ? " · " + reason : ""}`); persist(); notify(); return { ok: true, changes, notes, msg: `${u.stock} saved${notes.length ? " — " + notes.join(" ") : "."}` }; }, // Explicit accounting action: book the difference between the unit's // current cost and the cost already in the GL. Offset account is chosen // by the user (e.g. floorplan, A/P, recon). Sold units adjust cost of sale. // RC33 (mpx): a SERVER command now — MoneyCore.jsx.txt (loaded after this file) replaces it; this stub only answers when that file is missing postUnitCostAdjustment(stockOrVin, offsetAcct, reason) { return moneyMissing("A unit cost adjustment"); }, // Patch any unit by stock # or VIN (used by vAuto pricing import, feeds). updateUnit(stockOrVin, patch) { const u = api._unit(stockOrVin); if (!u) return { ok: false, msg: "Unit not found: " + stockOrVin }; Object.assign(u, patch); persist(); notify(); return { ok: true, msg: `Unit ${u.stock} updated.` }; }, // ════════════════════════════════════════════════════════════════ // DEMO MODE & DATA LIFECYCLE — the system ships EMPTY for real use. // Demo Mode loads practice data to learn with; exiting wipes business // data but keeps users, permissions, settings, and GL mappings. // ════════════════════════════════════════════════════════════════ isDemo() { try { return localStorage.getItem("jbrews-demo-mode") === "1"; } catch (e) { return false; } }, startDemo() { if (!dmsPrimaryStore()) return { ok: false, msg: DMS_PRIMARY_ONLY }; try { localStorage.setItem("jbrews-demo-mode", "1"); } catch (e) {} const r = api.importLegacy ? api.importLegacy(false) : { ok: false }; logAudit("Demo Mode", "ON — practice data loaded"); persist(); notify(); return { ok: true, msg: "Demo Mode ON — practice data loaded. Explore freely; Exit Demo wipes it and keeps your users & settings." + (r.ok ? "" : " (sample file not loaded — screens still show their built-in walkthroughs)") }; }, endDemo() { try { localStorage.setItem("jbrews-demo-mode", "0"); } catch (e) {} api.wipeBusinessData(); logAudit("Demo Mode", "OFF — demo data wiped"); return { ok: true, msg: "Demo Mode off — all demo/business data wiped. Users, permissions, settings, and GL mappings kept." }; }, // Clear business records only — config survives. wipeBusinessData() { ["deals", "repairOrders", "inventory", "parts", "partsOrders", "cores", "appointments", "payables", "journalEntries", "warrantyReceivables", "factoryReceivables", "appraisals", "bodyShopROs", "checks", "policyAdjustments", "arCustomers"].forEach(k => { if (Array.isArray(state[k])) state[k].length = 0; }); state.customers = {}; state.audit = []; persist(); notify(); return { ok: true, msg: "Business data cleared — users, settings, and GL mappings kept." }; }, // Update readiness flags on a unit (titleInHand, inspectionDone, payoffCleared, // reconDone, photosOnline, pricePosted, keysAccounted, inspectionRequired). setUnitReadiness(stock, patch) { const u = api._unit(stock); if (!u) return { ok: false, msg: "Unit not found." }; u.ready = { ...(u.ready || {}), ...patch }; logAudit("Vehicle Readiness", `${u.stock} — ${Object.keys(patch).map(k => k + ":" + (patch[k] ? "Y" : "N")).join(", ")}`); persist(); notify(); return { ok: true, msg: `${u.stock} readiness updated.` }; }, // ── Recon stage tracker — moves a used unit through the make-ready line. RECON_STAGES: ["Intake", "Inspection", "Estimate/Approval", "In Recon RO", "Parts", "Detail", "Photos", "Ready Line"], setReconStage(stock, stage) { const u = api._unit(stock); if (!u) return { ok: false, msg: "Unit not found." }; u.reconStage = stage; u.reconHist = u.reconHist || {}; if (!u.reconHist[stage]) u.reconHist[stage] = _iso(new Date()); if (stage === "Ready Line") u.ready = { ...(u.ready || {}), reconDone: true }; logAudit("Recon Stage", `${u.stock} → ${stage}`); persist(); notify(); return { ok: true, msg: `${u.stock} moved to ${stage}.` }; }, truthBox(stockOrVin) { const u = api._unit(stockOrVin); if (!u) return { found: false, canSell: false, items: [], openItems: { items: [] } }; const r = u.ready || {}; const isNew = /new/i.test(u.type || "") || String(u.type || "").toUpperCase().startsWith("N"); // Used vehicles need a safety inspection unless explicitly waived; new // units don't. inspectionRequired defaults from type but can be overridden. const inspReq = r.inspectionRequired != null ? r.inspectionRequired : !isNew; const pricePosted = r.pricePosted != null ? r.pricePosted : (+u.asking > 0); const rows = [ { key: "titleInHand", label: "Title in hand", ok: !!r.titleInHand, required: true }, { key: "inspectionDone",label: "Safety inspection", ok: !inspReq || !!r.inspectionDone, required: inspReq, na: !inspReq }, { key: "payoffCleared", label: "Prior payoff cleared", ok: isNew ? true : !!r.payoffCleared, required: !isNew, na: isNew }, { key: "reconDone", label: "Recon complete", ok: isNew ? true : !!r.reconDone, required: !isNew, na: isNew }, { key: "photosOnline", label: "Photos online", ok: !!r.photosOnline, required: false }, { key: "pricePosted", label: "Price posted", ok: !!pricePosted, required: true }, { key: "keysAccounted", label: "Keys accounted for", ok: !!r.keysAccounted, required: true }, ]; const open = api.unitOpenItems(u.stock, {}); const failed = rows.filter(x => x.required && !x.ok); const canSell = failed.length === 0 && open.blocks === 0; const reasons = [ ...failed.map(x => x.label), ...open.items.filter(i => i.severity === "block").map(i => i.label), ]; return { found: true, stock: u.stock, isNew, canSell, items: rows, openItems: open, reasons }; }, // ════════════════════════════════════════════════════════════════ // COST BASIS — true total cost of a stock unit: purchase + pack + // recon (internal labor/parts + sublet from ROs & body shop) so the // number isn't buried in accounting. Used by Inventory & the gross calc. // ════════════════════════════════════════════════════════════════ costBasis(stockOrVin) { const u = api._unit(stockOrVin); if (!u) return { found: false, total: 0, lines: [] }; const isInternal = (r) => /internal|recon|recondition|lot|make-ready|makeready/i.test((r.type || "") + " " + (r.payType || "") + " " + (r.payer || "")); const ros = (state.repairOrders || []).filter(r => (r.stock && String(r.stock).toUpperCase() === String(u.stock).toUpperCase()) || (u.vin && r.vin && String(r.vin).toUpperCase() === String(u.vin).toUpperCase() && isInternal(r))); const reconLabor = ros.reduce((s, r) => s + (+r.laborCost || +r.laborSale || 0), 0); const reconParts = ros.reduce((s, r) => s + (+r.partsCost || +r.partsSale || 0), 0); // Body shop recon on this unit (by VIN) const body = (state.bodyShopROs || []).filter(b => u.vin && String(b.vin || "").toUpperCase() === String(u.vin).toUpperCase() && /internal|recon/i.test(b.payType || "")); const sublet = ros.reduce((s, r) => s + (+r.subletCost || 0), 0) + body.reduce((s, b) => s + (+b.subletCost || 0), 0); const purchase = +u.cost || 0; const auctionFee = +u.auctionFee || 0; const pack = +u.pack || 0; const lines = [ { label: "Vehicle purchase", amount: purchase }, { label: "Auction / buy fee", amount: auctionFee }, { label: "Recon labor", amount: reconLabor }, { label: "Recon parts", amount: reconParts }, { label: "Sublet", amount: sublet }, { label: "Pack", amount: pack }, ].filter(x => x.amount > 0); const total = purchase + auctionFee + reconLabor + reconParts + sublet + pack; return { found: true, stock: u.stock, total: +total.toFixed(2), reconTotal: +(reconLabor + reconParts + sublet).toFixed(2), purchase, auctionFee, reconLabor, reconParts, sublet, pack, roCount: ros.length + body.length, lines }; }, // ════════════════════════════════════════════════════════════════ // VEHICLE PROFIT TIMELINE — life of a stock # as dated events: // bought → recon started/finished → listed → sold → funded → // commission paid → title complete → gross finalized. Shows how long // the unit sat and where time (and profit) leaked. // ════════════════════════════════════════════════════════════════ profitTimeline(stockOrVin) { const u = api._unit(stockOrVin); if (!u) return { found: false, events: [] }; const deal = (state.deals || []).find(d => String(d.stock || "").toUpperCase() === String(u.stock).toUpperCase()) || null; const cb = api.costBasis(u.stock); const reconHist = u.reconHist || {}; const ev = []; const add = (label, date, kind, detail) => { if (date) ev.push({ label, date, kind, detail: detail || "" }); }; add("Vehicle bought", u.stockedDate || u.created, "buy", cb.purchase ? "$" + cb.purchase.toLocaleString() + " cost" : ""); add("Recon started", reconHist["Intake"] || reconHist["Inspection"], "recon"); add("Recon finished", reconHist["Ready Line"] || (u.ready && u.ready.reconDone ? (reconHist["Photos"] || reconHist["Detail"]) : null), "recon", cb.reconTotal ? "$" + cb.reconTotal.toLocaleString() + " recon" : ""); add("Listed online", u.listedDate || (u.ready && u.ready.photosOnline ? reconHist["Photos"] : null), "list"); if (deal) { add("Sold", deal.soldDate || deal.created, "sold", deal.buyer); add("Delivered", deal.delivered, "sold"); add("Posted to GL", deal.postedToGL ? (deal.postedDate || deal.delivered || deal.created) : null, "post"); add("Funded", deal.fundedDate, "fund", deal.fundingStatus || ""); const comm = (state.commissions || []).find(c => c.deal === deal.id); add("Commission paid", comm && comm.paid ? comm.paidDate : null, "comm", comm ? "$" + (comm.commission || comm.amount || 0).toLocaleString() : ""); } add("Title complete", (u.ready && u.ready.titleInHand) ? (u.titleDate || (deal && deal.fundedDate)) : null, "title"); ev.sort((a, b) => new Date(a.date) - new Date(b.date)); // Gross finalized once funded let gross = null, front = null, back = null; if (deal) { front = (+deal.salePrice || 0) - (cb.total || +deal.cost || 0) - (deal.tradeAllow ? ((+deal.tradeAllow || 0) - (+deal.tradeACV || +deal.tradeAllow || 0)) : 0); back = +deal.fiGross || ((+deal.vscSale || 0) - (+deal.vscCost || 0) + (+deal.gapSale || 0) - (+deal.gapCost || 0)); // RC28 (fx, gate 2b): the deal's cost lines (Profit panel) come off the gross exactly as on the desk and in the posting if (window.DealCalc && Array.isArray(deal.costAdds) && deal.costAdds.length) { const ca = window.DealCalc.costAddsOf(deal); front -= ca.frontCents / 100; back -= ca.backCents / 100; } gross = front + back; } const firstD = ev[0] ? new Date(ev[0].date) : null; const daysHeld = firstD ? Math.floor((Date.now() - firstD) / 86400000) : 0; // age at sale let daysToSale = null; const soldEv = ev.find(e => e.kind === "sold"); if (soldEv && firstD) daysToSale = Math.max(0, Math.floor((new Date(soldEv.date) - firstD) / 86400000)); return { found: true, stock: u.stock, vehicle: `${u.yr} ${u.mk} ${u.mdl}`, events: ev, daysHeld, daysToSale, costBasis: cb.total, deal: deal ? deal.id : null, front, back, gross, finalized: !!(deal && deal.status === "Funded") }; }, submitToLenders({ deal, buyer, vehicle, stock, amount, fico, lenders }) { if (!Array.isArray(lenders) || !lenders.length) return { ok: false, msg: "Select at least one lender." }; if (!state.lenderSubmissions) state.lenderSubmissions = []; const batch = "CA-" + String(Date.now()).slice(-6); const now = Date.now(); lenders.forEach((L, i) => { state.lenderSubmissions.unshift({ id: `${batch}-${i + 1}`, batch, deal: deal || "", buyer: buyer || "", vehicle: vehicle || "", stock: stock || "", amount: +amount || 0, fico: +fico || 0, lender: (L && L.name) || String(L), lenderCode: (L && L.code) || String(L).slice(0, 6).toUpperCase(), status: "SUBMITTED", sentAt: now, tier: null, buyRate: null, maxAmt: null, term: null, stip: "", }); }); logAudit("Credit App Submitted", `${buyer || "Applicant"} → ${lenders.length} lender(s), batch ${batch}`); persist(); notify(); return { ok: true, batch, msg: `Routed to ${lenders.length} lender${lenders.length !== 1 ? "s" : ""} — no decision yet. Decisions are recorded when the lender responds.` }; }, // Record a REAL lender decision on a submission (from the lender // integration / RouteOne post-back, or keyed by F&I from the lender's // callback). There is no simulator (A-11): a submission stays "SUBMITTED" // (shown as pending / "no decision yet") until this is called. // status: "APPROVED" | "COND" | "DECLINED"; tier/buyRate/maxAmt/term/stip optional. recordLenderDecision(submissionId, decision) { const s = (state.lenderSubmissions || []).find(x => x && x.id === submissionId); if (!s) return { ok: false, msg: "Submission not found." }; const d = decision || {}; const status = String(d.status || "").toUpperCase(); if (!["APPROVED", "COND", "DECLINED"].includes(status)) return { ok: false, msg: "Decision must be APPROVED, COND or DECLINED." }; s.status = status; s.decidedAt = Date.now(); s.tier = d.tier != null ? d.tier : s.tier; s.buyRate = d.buyRate != null ? +d.buyRate : s.buyRate; s.maxAmt = d.maxAmt != null ? +d.maxAmt : s.maxAmt; s.term = d.term != null ? +d.term : s.term; s.stip = d.stip != null ? String(d.stip) : s.stip; logAudit("Lender Decision", `${s.lender}: ${status}${s.buyRate ? " @ " + (+s.buyRate).toFixed(2) + "%" : ""} — ${s.buyer || s.batch}`); persist(); notify(); return { ok: true, msg: `${s.lender}: ${status} recorded.` }; }, submitWarrantyClaim(claimId) { const w = state.warrantyReceivables.find(x => x.claim === claimId); if (!w) return { ok: false, msg: "Claim not found." }; if (!w.complaintCode || !w.causeCode) return { ok: false, msg: "Complaint and Cause codes are required before submission." }; w.submitted = true; w.status = "Submitted"; w.submittedDate = _iso(new Date()); w.txnType = w.txnType || "ZREG"; logAudit("Warranty Claim Submitted", `${claimId} (${w.ro}) — $${(+w.amount||0).toFixed(2)} as ${w.txnType}`); persist(); notify(); return { ok: true, msg: `Claim ${claimId} submitted to GWM — $${w.amount.toLocaleString()} (${w.txnType}).` }; }, // Edit claim coding (complaint/cause/labor-op/causal part/txn type/auth). gwmUpdateClaim(claimId, patch) { const w = state.warrantyReceivables.find(x => x.claim === claimId); if (!w) return { ok: false, msg: "Claim not found." }; Object.assign(w, patch); persist(); notify(); return { ok: true, msg: `Claim ${claimId} updated.` }; }, // GM authorizes the submitted claim (records the authorization code). authorizeWarrantyClaim(claimId, authCode) { const w = state.warrantyReceivables.find(x => x.claim === claimId); if (!w) return { ok: false, msg: "Claim not found." }; if (!w.submitted) return { ok: false, msg: "Submit the claim before authorization." }; w.status = "Authorized"; w.authCode = authCode || w.authCode || "G"; w.authorizedDate = _iso(new Date()); logAudit("Warranty Claim Authorized", `${claimId} — auth ${w.authCode}`); persist(); notify(); return { ok: true, msg: `Claim ${claimId} authorized (code ${w.authCode}).` }; }, // GWM pays the claim → cash in, relieve warranty receivable (acct 263). // RC33 (mpx): a SERVER command now — MoneyCore.jsx.txt (loaded after this file) replaces it; this stub only answers when that file is missing payWarrantyClaim(claimId) { return moneyMissing("A warranty payment"); }, // GWM rejects → records reject code; claim can be corrected & resubmitted. rejectWarrantyClaim(claimId, rejectCode) { const w = state.warrantyReceivables.find(x => x.claim === claimId); if (!w) return { ok: false, msg: "Claim not found." }; w.status = "Rejected"; w.rejectCode = rejectCode || w.rejectCode || "4"; w.rejectedDate = _iso(new Date()); w.submitted = false; // must correct & resubmit logAudit("Warranty Claim Rejected", `${claimId} — reject ${w.rejectCode}`); persist(); notify(); return { ok: true, msg: `Claim ${claimId} rejected (code ${w.rejectCode}). Correct and resubmit.` }; }, // Full Debit / chargeback — reverses a paid (or receivable) claim to warranty // expense. Used for audit / part-not-returned / non-compliant repairs. // RC33 (mpx): a SERVER command now — MoneyCore.jsx.txt (loaded after this file) replaces it; this stub only answers when that file is missing debitWarrantyClaim(claimId, debitCode) { return moneyMissing("A warranty chargeback"); }, // WPC part-return tracking status on a claim. setWarrantyReturnStatus(claimId, status) { const w = state.warrantyReceivables.find(x => x.claim === claimId); if (!w) return { ok: false, msg: "Claim not found." }; w.returnStatus = status; if (status === "Inspected — Debit") w.flaggedForDebit = true; logAudit("Warranty Part Return", `${claimId} — ${status}`); persist(); notify(); return { ok: true, msg: `${claimId} part return: ${status}.` }; }, // RC33 (mpx): a SERVER command now — MoneyCore.jsx.txt (loaded after this file) replaces it; this stub only answers when that file is missing receiveFactoryIncentive(deal) { return moneyMissing("A factory receipt"); }, // ════════════════════════════════════════════════════════════════ // PARTS DEPARTMENT // ════════════════════════════════════════════════════════════════ // Matrix markup (protects gross). Returns suggested retail from cost. matrixPrice(cost) { const c = Number(cost) || 0; let mult; if (c <= 5) mult = 3.0; // 200% markup else if (c <= 25) mult = 2.5; // 150% else if (c <= 100) mult = 2.0; // 100% else if (c <= 500) mult = 1.6; // 60% else mult = 1.4; // 40% return +(c * mult).toFixed(2); }, addPart(rec) { // RC26 (inv) fix round (audit F5): the part number is the same part whatever the capitals / spacing (the server refuses a second copy // too — 409 part_exists), and a new part starts with 0 on hand: its stock goes in with Physical Inventory › Post count or a PO receipt const pn = String(rec.part || "").replace(/[\u00AD\u200B-\u200F\u2060-\u206F\uFEFF]/g, "").trim().toUpperCase(); if (!pn) return { ok: false, msg: "Part number is required." }; const dup = state.parts.find(p => String(p.part || "").trim().toUpperCase() === pn); if (dup) return { ok: false, msg: `Part ${dup.part} already exists — open it instead of adding it again.` }; state.parts.unshift({ part: pn, desc: rec.desc || "", mfr: rec.mfr || "ACDelco", source: rec.source || "GM", bin: rec.bin || "", onHand: 0, committed: 0, onOrder: 0, cost: +rec.cost || 0, list: +rec.list || api.matrixPrice(rec.cost), trade: +rec.trade || +(((+rec.list) || api.matrixPrice(rec.cost)) * 0.8).toFixed(2), lastSale: null, lastReceipt: _iso(new Date()), age: 0, returnable: rec.returnable !== false, core: +rec.core || 0, stockStatus: rec.stockStatus || "Stocked", }); persist(); notify(); return { ok: true, msg: `Part ${pn} added to master file (0 on hand — count it on Physical Inventory or receive it on a PO).` }; }, updatePart(partNo, patch) { const p = state.parts.find(x => x.part === partNo); if (!p) return { ok: false, msg: "Part not found." }; Object.assign(p, patch); persist(); notify(); return { ok: true, msg: `Part ${partNo} updated.` }; }, // ════════════════════════════════════════════════════════════════ // Import the MASTER GM PARTS LIST (price tape / catalog feed). // Each row updates catalog fields (desc, cost, list, trade, mfr, // source, bin, core) on matching part numbers and ADDS new ones. // Dealer-owned quantities (onHand / committed / onOrder) are NEVER // touched — the master list is pricing/catalog, not your stock. // opts.updatePrices=false imports only NEW parts and leaves the // prices of parts you already stock alone. // ════════════════════════════════════════════════════════════════ // RC25 (imp): opts.applyOnHand=true (the wizard's "Parts — master list and on-hand" destination) also sets each part's // on-hand count from the file when the row carries one (the old system's count); the Parts screen button never passes it, // so its behaviour is unchanged. opts.quiet=true skips the audit line (the wizard writes its own import-history record). importPartsMaster(rows, opts = {}) { if (!Array.isArray(rows) || !rows.length) return { ok: false, msg: "No parts rows to import." }; const updatePrices = opts.updatePrices !== false; const applyOnHand = opts.applyOnHand === true; const hasQty = (r) => applyOnHand && r.onHand != null && r.onHand !== "" && Number.isFinite(+r.onHand); let added = 0, updated = 0, skipped = 0, counted = 0; rows.forEach(r => { const partNo = String(r.part || r.partNo || r["part #"] || "").trim().toUpperCase(); if (!partNo) { skipped++; return; } const cost = r.cost != null && r.cost !== "" ? +r.cost : null; const list = r.list != null && r.list !== "" ? +r.list : (cost != null ? api.matrixPrice(cost) : null); const trade = r.trade != null && r.trade !== "" ? +r.trade : (list != null ? +(list * 0.8).toFixed(2) : null); const existing = state.parts.find(p => p.part === partNo) || state.parts.find(p => String(p.part || "").trim().toUpperCase() === partNo); // RC26 (inv) fix round: whatever the capitals if (existing) { if (r.desc) existing.desc = String(r.desc).toUpperCase(); if (r.mfr) existing.mfr = r.mfr; if (r.source) existing.source = r.source; if (r.bin) existing.bin = String(r.bin).toUpperCase(); if (r.core != null && r.core !== "") existing.core = +r.core || 0; if (updatePrices) { if (cost != null) existing.cost = cost; if (list != null) existing.list = list; if (trade != null) existing.trade = trade; existing.masterPriced = true; // came from the master tape } if (hasQty(r)) { if (+existing.onHand !== +r.onHand) existing.importCount = true; existing.onHand = +r.onHand; if (+r.onHand > 0 && existing.stockStatus === "Catalog") existing.stockStatus = "Stocked"; existing.countedAt = _iso(new Date()); counted++; } // RC25 (imp); RC26 (inv): importCount = the old system's count (no money) — the only on-hand change the server takes from a saved part // quantities otherwise untouched on purpose updated++; } else { state.parts.push({ part: partNo, desc: String(r.desc || "").toUpperCase(), mfr: r.mfr || "ACDelco", source: r.source || "GM", bin: String(r.bin || "").toUpperCase(), onHand: hasQty(r) ? +r.onHand : 0, committed: 0, onOrder: 0, cost: cost || 0, list: list || 0, trade: trade || 0, lastSale: null, lastReceipt: null, age: 0, returnable: true, core: +r.core || 0, stockStatus: hasQty(r) && +r.onHand > 0 ? "Stocked" : "Catalog", masterPriced: true, ...(hasQty(r) ? { countedAt: _iso(new Date()) } : {}), }); if (hasQty(r)) counted++; added++; } }); if (!opts.quiet) logAudit && logAudit("Parts Master Imported", `GM master list: +${added} new, ${updated} repriced, ${skipped} skipped${applyOnHand ? ", " + counted + " on-hand counts set" : ""}`); persist(); notify(); return { ok: true, added, updated, skipped, counted, msg: `Master list imported — ${added} new part${added !== 1 ? "s" : ""}, ${updated} repriced${skipped ? ", " + skipped + " skipped" : ""}. ${applyOnHand ? counted + " on-hand count" + (counted === 1 ? "" : "s") + " set from the file." : "On-hand quantities unchanged."}` }; }, // ════════════════════════════════════════════════════════════════ // RC26 (inv) — the PARTS & INVENTORY money are SERVER COMMANDS (src/routes/parts.js): a physical count, a PO receipt, the core bank // steps, a bulk delivery / dispense and a counter return each go to the server as ONE command (Idempotency-Key; the server re-reads, // checks the role, moves the stock, posts the books with the station's accounts) — this station shows success ONLY from the committed // answer and takes the server's records back. Nothing here changes a part's on-hand, a PO, a core or a tank by itself any more. // ════════════════════════════════════════════════════════════════ async _partsCommand(path, body, prefix, what) { if (!AUTH_TOKEN || __offlineByChoice) return { ok: false, msg: `${what} posts to the books, which needs the server. Connect to the server, then try again. Nothing was changed.` }; const key = api._newIdemKey(prefix); const r = await api._command(path, body, key, "/ops/op/"); if (!r.ok) { const b = (r.err && r.err.body) || {}; if (r.unknown) return { ok: false, unknown: true, key, msg: `The server did not answer. Look at ${what} before trying again (the office can look up ${key}).` }; return { ok: false, key, code: b.code, open: b.open, left: b.left, msg: b.error || (r.err && r.err.message) || "The server refused this. Nothing was changed." }; } api._applyServerRecords(r.res.records); logAudit("Parts", `${path.replace(/^\/parts\//, "")} — ${r.res.message || "committed"}${r.recovered ? " (answer recovered after a dropped connection)" : ""}`); notify(); const w = Array.isArray(r.res.warnings) && r.res.warnings.length ? " ⚠ " + r.res.warnings.join(" ") : ""; return Object.assign({}, r.res, { ok: true, key, recovered: !!r.recovered, msg: (r.res.message || "Done.") + w }); }, // Physical inventory (Parts Dept › Physical Inventory › Post): the counted quantity; the server posts the variance at the part's cost (PI-, 242 / 688) adjustInventory(partNo, countedQty, reason) { const p = (state.parts || []).find(x => x.part === partNo); return api._partsCommand("/parts/adjust", { partNo, countedQty: countedQty === "" || countedQty == null ? null : String(countedQty), reason: reason || "", onHandSeen: p ? p.onHand : undefined }, "pi", "the count of " + partNo); }, partsConfig() { return apiGet("/parts/config").catch(e => ({ ok: false, msg: (e && e.body && e.body.error) || (e && e.message) || "Could not load." })); }, // ════════════════════════════════════════════════════════════════ // RC20 (fops) — PARTS COUNTER CHECKOUT is ONE server command (POST /api/parts/checkout): the whole ticket at once. The server // checks the role, locks the parts, re-checks what is available, prices from the parts file (or the line's one-time override), // figures ONE tax on the taxable subtotal with the shared rate, relieves the stock, posts ONE journal entry (PT-) and // stores the invoice — or does nothing and says why. The ticket on the screen is kept until the server says "committed". // ════════════════════════════════════════════════════════════════ // Preview of a ticket (the screen and the quote print). lines [{ part, qty, overrideUnit? }] → FopsCalc ticket in cents. partsTicketPreview(lines, kind) { const F = window.FopsCalc; const tl = (lines || []).map(l => { const p = (state.parts || []).find(x => x.part === l.part) || {}; const base = F.toCents(kind === "wholesale" ? p.trade : p.list); const o = l.overrideUnit != null && l.overrideUnit !== "" ? F.toCents(l.overrideUnit) : NaN; const ovr = isFinite(o) && o >= 0 && o !== base; const core = F.toCents(p.core); return { part: l.part, desc: p.desc || "", bin: p.bin || "", qty: +l.qty || 0, unitCents: ovr ? o : (isFinite(base) ? base : 0), baseUnitCents: isFinite(base) ? base : 0, costCents: F.toCents(p.cost) || 0, override: ovr, coreCents: isFinite(core) && core > 0 ? core : 0 }; }); // RC28 (fx, gate 2c): the core deposit is COLLECTED on the ticket (added to the total, held until the core comes back) — the server does the same return F.partsTicket({ kind: kind === "wholesale" ? "wholesale" : "retail", lines: tl, collectCores: true }, api.fopsTaxConfig()); }, async partsCheckout(t) { t = t || {}; const lines = (t.lines || []).filter(l => l && l.part && (+l.qty || 0) > 0); if (!lines.length) return { ok: false, msg: "Ticket is empty — add parts first." }; if (!api.fopsCanSellParts()) return { ok: false, denied: true, msg: `Your role (${currentRole() || "no role"}) can't sell parts — the parts counter, a service advisor or the office can.` }; if (!AUTH_TOKEN || __offlineByChoice) return { ok: false, msg: "Cashing out a ticket posts it to the books, which needs the server. Connect to the server, then cash out. The ticket is kept." }; const body = { ticket: lines.map(l => { const o = { part: l.part, qty: +l.qty }; if (l.overrideUnit != null && l.overrideUnit !== "") { o.overrideUnit = +l.overrideUnit; if (l.overrideReason) o.overrideReason = String(l.overrideReason); } return o; }), kind: t.kind === "wholesale" ? "wholesale" : "retail", customer: t.customer || undefined, tender: t.tender || undefined }; const key = t.idemKey || api._newIdemKey("pt"); const r = await api._command("/parts/checkout", 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. The ticket is kept — look at Recent tickets before cashing out again (the office can look up " + key + ")." }; return { ok: false, key, code: b.code, part: b.part, msg: b.error || (e && e.message) || "The server refused this ticket. Nothing was sold; the ticket is kept." }; } const res = r.res; api._applyServerRecords(res.records); const inv = res.invoice || {}; logAudit("Parts Ticket", `${res.saleId} · ${(inv.lines || []).length} line(s) · $${((inv.totalCents || 0) / 100).toFixed(2)} ${inv.kind || ""} · ${(inv.tender && inv.tender.method) || "cash"}${r.recovered ? " (answer recovered after a dropped connection)" : ""}`); notify(); return { ok: true, saleId: res.saleId, ticketNo: res.ticketNo, invoice: inv, records: res.records, key, recovered: !!r.recovered, msg: `Ticket ${res.saleId} posted — $${((inv.totalCents || 0) / 100).toFixed(2)}${inv.taxCents ? " incl. $" + (inv.taxCents / 100).toFixed(2) + " tax" : ""}${inv.coreDepositCents ? " and a $" + (inv.coreDepositCents / 100).toFixed(2) + " core deposit (refunded when the core comes back)" : ""}. Inventory relieved.` }; }, // One part (Parts Dept › Sell): the same command with a one-line ticket. overrideUnit = a one-time price for THIS sale only // (round 2: with its reason — required by the server for a part that has no price on file). sellPart(partNo, qty, kind, overrideUnit, overrideReason) { return api.partsCheckout({ lines: [{ part: partNo, qty, overrideUnit, overrideReason }], kind }); }, // Stored invoices (reprint never moves stock or money again) async partsSales(limit) { try { const r = await apiGet("/parts/sales?limit=" + (limit || 50)); return { ok: true, sales: r.sales || [] }; } catch (e) { return { ok: false, sales: [], msg: (e && e.body && e.body.error) || (e && e.message) || "Could not load the tickets." }; } }, async partsSale(saleId) { try { const r = await apiGet("/parts/sales/" + encodeURIComponent(saleId)); return { ok: true, sale: r.sale }; } catch (e) { return { ok: false, msg: (e && e.body && e.body.error) || (e && e.message) || "Could not load the ticket." }; } }, // RC26 (inv): a counter RETURN against a stored ticket (never more than sold minus already returned; restock / defective / core; refund cash, // card, back to the A/R account, or a check through the check command — the office). The invoice itself never changes; the return is its own document. partsReturn(saleId, lines, refund, reason) { return api._partsCommand("/parts/return", { saleId, lines: (lines || []).map(l => ({ lineId: l.lineId != null ? l.lineId : l.lineIndex, qty: String(l.qty), disposition: l.disposition || "restock" })), refund: refund || { method: "cash" }, reason: reason || "" }, "rt", "ticket " + saleId); }, async partsReturns(saleId) { try { const r = await apiGet("/parts/returns" + (saleId ? "?saleId=" + encodeURIComponent(saleId) : "")); return { ok: true, returns: r.returns || [] }; } catch (e) { return { ok: false, returns: [], msg: (e && e.body && e.body.error) || (e && e.message) || "Could not load the returns." }; } }, // RC26 (inv): the Recon Tracker's one-time "Catch up serviced cars" (office) — closed internal ROs before RC26 stamped on their units, once async reconCatchUpPreview() { try { return await apiGet("/recon/catch-up"); } catch (e) { return { ok: false, msg: (e && e.body && e.body.error) || (e && e.message) || "Could not load." }; } }, reconCatchUp() { return api._partsCommand("/recon/catch-up", {}, "rcu", "the recon catch-up"); }, // RC20 catch-up (office): the RO / parts work since RC16 that has no journal entry, and "Post it now" async fopsUnposted(since) { try { const r = await apiGet("/fixedops/unposted" + (since ? "?since=" + encodeURIComponent(since) : "")); return Object.assign({ ok: true }, r); } catch (e) { return { ok: false, msg: (e && e.body && e.body.error) || (e && e.message) || "Could not load the list." }; } }, async fopsPostMissing(item) { if (!item) return { ok: false, msg: "Nothing to post." }; // RC24: an "other" item a lane's catch-up command can post carries its own command and body (svcm: /fixedops/post-missing { auditId }) const path = item.cmd ? item.cmd : item.type === "ro" ? "/ro/post-missing" : "/parts/post-missing"; const body = item.cmd ? (item.postBody || { auditId: item.auditId }) : item.type === "ro" ? { roId: item.roId } : { saleId: item.saleId }; const key = api._newIdemKey("pm"); const r = await api._command(path, body, key, "/ops/op/"); if (!r.ok) { const b = (r.err && r.err.body) || {}; return { ok: false, unknown: !!r.unknown, code: b.code, msg: b.error || (r.unknown ? "The server did not answer — refresh the list before posting again." : "The server refused.") }; } api._applyServerRecords(r.res.records); logAudit("Posted Late", `${item.what || (item.type === "ro" ? item.roId : item.journalId)} → ${r.res.jeId}${r.res.alreadyPosted ? " (already on the books)" : ""}`); notify(); return { ok: true, alreadyPosted: !!r.res.alreadyPosted, jeId: r.res.jeId, msg: r.res.message || `${item.what || (item.type === "ro" ? item.roId : item.journalId)} posted as ${r.res.jeId} (dated ${r.res.date}).` }; }, // The shared tax settings (office / Service Manager): serviceSettings.tax — every station's preview and the server's commands use it fopsSetSharedTax(patch) { if (!api.fopsSeesShopCost()) return { ok: false, denied: true, msg: "The tax rates are set by the office or the Service Manager — they are the same for every station." }; const F = window.FopsCalc; const cur = (state.serviceSettings && state.serviceSettings.tax) || {}; const next = Object.assign({}, cur, patch || {}); const prob = F.taxConfigProblem(next); if (prob) return { ok: false, msg: prob }; state.serviceSettings = Object.assign({}, state.serviceSettings || {}, { tax: next }); logAudit("Tax Settings", Object.keys(patch || {}).map(k => k + " → " + JSON.stringify(patch[k])).join("; ")); persist(); notify(); return { ok: true, msg: "Tax settings saved for every station." }; }, // RC26 (inv): Receive on a purchase order — the lines that arrived (partial receipts allowed); the server puts them in stock at cost, creates ONE // vendor bill for the receipt (A/P 300 — the check command pays it) and posts AP--R (DR 242 / CR 300). opts { lines:[{partNo, qtyReceived, unitCost?}], vendorInvoice, note } receivePO(poNo, opts) { const po = (state.partsOrders || []).find(o => o.po === poNo); if (!po) return Promise.resolve({ ok: false, msg: "PO not found." }); const o = opts || {}; const lines = o.lines || (po.lines || []).map(l => ({ partNo: l.part, qtyReceived: Math.max(0, (+l.qty || 0) - (+l.received || 0)) })).filter(l => l.qtyReceived > 0); if (!lines.length) return Promise.resolve({ ok: false, msg: `${poNo} has nothing open to receive.` }); return api._partsCommand("/parts/po/receive", { poNo, lines, vendorInvoice: o.vendorInvoice || "", note: o.note || "" }, "ap", "purchase order " + poNo); }, poReceipts(poNo) { return apiGet("/parts/po/" + encodeURIComponent(poNo) + "/receipts").catch(e => ({ ok: false, receipts: [], msg: (e && e.body && e.body.error) || (e && e.message) || "Could not load." })); }, createPO(type, vendor, lines) { const po = { po: "PO-" + String(Date.now()).slice(-5), type: type || "Stock Order", vendor: vendor || "GM Parts", created: _iso(new Date()), status: "Open", lines: lines || [] }; lines && lines.forEach(l => { const p = state.parts.find(x => x.part === l.part); if (p) p.onOrder += l.qty; }); state.partsOrders = state.partsOrders || []; state.partsOrders.unshift(po); persist(); notify(); return { ok: true, msg: `${po.po} created (${po.type}).`, po: po.po }; }, // Special order workflow markSpecialOrderInstalled(partNo) { const p = state.parts.find(x => x.part === partNo); if (!p || !p.specialOrder) return { ok: false, msg: "No special order on this part." }; p.specialOrder.installed = true; // RC26 (inv): the on-hand is the server's — the part leaves stock when its RO is closed (or its ticket is cashed out) persist(); notify(); return { ok: true, msg: `Special order ${partNo} marked installed for ${p.specialOrder.customer}. The part leaves stock when its RO is closed.` }; }, contactSpecialOrderCustomer(partNo) { const p = state.parts.find(x => x.part === partNo); if (!p || !p.specialOrder) return { ok: false, msg: "No special order on this part." }; p.specialOrder.contacted = true; p.specialOrder.contactedDate = _iso(new Date()); persist(); notify(); return { ok: true, msg: `Logged customer contact for ${p.specialOrder.customer}.` }; }, // Core returns (legacy single-step) — RC26 (inv): retired; a core moves through the Core Bank buttons (server commands), one step at a time returnCore(idx) { return { ok: false, msg: "Use the Core Bank buttons (Receive Core / Ship to GM / Post Credit) — the server moves a core one step at a time and posts each step once." }; }, // ════════════════════════════════════════════════════════════════ // CORE BANK — full GM remanufactured-core return lifecycle. // Engine / Transmission / Battery (and other reman units) each carry a // refundable core deposit. Lifecycle: // Due from Customer → customer owes the old unit (deposit billed) // On Core Shelf → old unit returned, deposit refunded, core held // Shipped to Mfr → core shipped to GM on a return claim // Credited → GM core credit received (cleared) // ════════════════════════════════════════════════════════════════ coreCategory(text) { const t = String(text || "").toLowerCase(); if (/engine|long block|short block|crate motor|cylinder head/.test(t)) return "Engine"; if (/trans|transmission|gearbox|valve body|torque conv/.test(t)) return "Transmission"; if (/battery|agm|ev battery|hv battery|module pack/.test(t)) return "Battery"; if (/alternator|generator/.test(t)) return "Alternator"; if (/starter/.test(t)) return "Starter"; if (/caliper|brake/.test(t)) return "Brake"; if (/axle|cv shaft|drive shaft|differential|carrier/.test(t)) return "Driveline"; if (/turbo|supercharger/.test(t)) return "Turbo"; return "Other"; }, addCore(rec) { if (!rec) return { ok: false, msg: "No core data." }; state.cores = state.cores || []; const amount = +rec.amount || 0; if (amount <= 0) return { ok: false, msg: "Core charge must be greater than zero." }; const c = { id: "CR-" + String(Date.now()).slice(-6), part: rec.part || "", desc: rec.desc || "", coreType: rec.coreType || api.coreCategory((rec.desc || "") + " " + (rec.part || "")), ro: rec.ro || "COUNTER", customer: rec.customer || "", amount, status: "Due from Customer", created: _iso(new Date()), age: 0, }; state.cores.unshift(c); logAudit("Core Charged", `${c.coreType} core ${c.part || c.desc} — $${amount.toFixed(2)} (${c.ro})`); persist(); notify(); return { ok: true, id: c.id, msg: `${c.coreType} core logged — $${amount.toFixed(2)} due from customer.` }; }, // RC26 (inv): the core bank steps are server commands — Receive Core (COC-: DR 246 / CR 202 bank, or 201 the till), Ship to GM (the vendor // credit document, no money yet), Post Credit (COG-: DR 202 / CR 246, once per core) _coreOf(id) { return (state.cores || []).find(x => x.id === id) || (state.cores || []).find(x => x.part === id); }, collectCore(id, refundMethod) { const c = api._coreOf(id); if (!c) return Promise.resolve({ ok: false, msg: "Core not found." }); // RC28 (fx, gate 2c): a core whose deposit was collected on a ticket CHARGED to an A/R account gets the deposit back on that account const dflt = c.depositCollected && c.tender === "charge" ? "ar" : "bank"; return api._partsCommand("/parts/cores/receive-from-customer", { coreId: c.id, refundMethod: refundMethod || dflt }, "coc", "core " + c.id); }, shipCore(id, claimNo, vendor) { const c = api._coreOf(id); if (!c) return Promise.resolve({ ok: false, msg: "Core not found." }); return api._partsCommand("/parts/cores/return-to-vendor", { coreId: c.id, claimNo: claimNo || "", vendor: vendor || "" }, "cos", "core " + c.id); }, creditCore(id) { const c = api._coreOf(id); if (!c) return Promise.resolve({ ok: false, msg: "Core not found." }); return api._partsCommand("/parts/cores/credit", { coreId: c.id }, "cog", "core " + c.id); }, coreBankSummary() { const cores = state.cores || []; const open = cores.filter(c => c.status !== "Credited" && c.status !== "Part Returned"); // RC26 (inv) fix round: a core closed by a counter return is not open const byType = {}; open.forEach(c => { const k = c.coreType || "Other"; byType[k] = byType[k] || { type: k, count: 0, amount: 0, dueCust: 0, onShelf: 0, shipped: 0 }; byType[k].count += 1; byType[k].amount += +c.amount || 0; if (c.status === "Due from Customer") byType[k].dueCust += +c.amount || 0; else if (c.status === "On Core Shelf") byType[k].onShelf += +c.amount || 0; else if (c.status === "Shipped to Mfr") byType[k].shipped += +c.amount || 0; }); return { rows: Object.values(byType).sort((a, b) => b.amount - a.amount), totalOpen: open.reduce((s, c) => s + (+c.amount || 0), 0), dueFromCustomer: open.filter(c => c.status === "Due from Customer").reduce((s, c) => s + (+c.amount || 0), 0), onShelf: open.filter(c => c.status === "On Core Shelf").reduce((s, c) => s + (+c.amount || 0), 0), shipped: open.filter(c => c.status === "Shipped to Mfr").reduce((s, c) => s + (+c.amount || 0), 0), count: open.length, }; }, // ════════════════════════════════════════════════════════════════ // SERVICE DEPARTMENT // ════════════════════════════════════════════════════════════════ // Schedule a new appointment (sales features H1): needs the service capability, a date and a // customer (picked from the customer file, or a walk-in name). Collision-safe id "APT-" plus a // creation nonce `_cid`; the customer number and VIN travel with it so the salesperson who sold the // car can be told (the server's alert hook reads custNum / veh.vin / date / serviceType). addAppointment(appt) { if (!api.can("service")) return { ok: false, denied: true, msg: "Your role can't schedule service - ask a service advisor." }; appt = appt || {}; const date = String(appt.date || "").trim(); if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return { ok: false, msg: "Pick a date." }; const time = /^\d{2}:\d{2}$/.test(String(appt.time || "")) ? String(appt.time) : "08:00"; const c = appt.cust && typeof appt.cust === "object" ? appt.cust : null; const custNum = String(appt.custNum || (c && c.custNum) || "").trim(); const customer = String(appt.customer || (c && c.name) || "").trim().toUpperCase(); if (!custNum && !customer) return { ok: false, msg: "Pick the customer, or tick walk-in and type a name." }; const v = appt.veh && typeof appt.veh === "object" ? appt.veh : null; const vin = String((v && v.vin) || appt.vin || "").replace(/[\s-]/g, "").toUpperCase(); const veh = (v || vin) ? { vin, yr: String((v && v.yr) || ""), mk: String((v && v.mk) || ""), mdl: String((v && v.mdl) || ""), miles: String((v && v.miles) || "") } : null; const vehicle = String(appt.vehicle || (veh ? [veh.yr, veh.mk, veh.mdl].filter(Boolean).join(" ") : "") || "").trim(); const serviceType = api.svcApptTypes().includes(appt.serviceType) || api.SVC_APPT_TYPES.includes(appt.serviceType) ? appt.serviceType : "Other"; // RC32 (L10) const now = new Date().toISOString(); const me = (currentUser && currentUser.user) || ""; state.appointments = state.appointments || []; const id = api._uid("APT"); state.appointments.push({ id, _cid: Math.random().toString(36).slice(2, 7) + Math.random().toString(36).slice(2, 7), date, time, customer: customer || custNum, custNum, cust: custNum ? { custNum, name: String((c && c.name) || customer || ""), phone: String((c && c.phone) || ""), email: String((c && c.email) || "") } : null, vehicle, vin, veh, serviceType, concern: String(appt.concern || ""), advisor: appt.advisor || "Mark Service", techId: appt.techId || "", estHours: +appt.estHours || 1.0, transport: appt.transport || "Drop-off", status: "Scheduled", createdBy: me, createdAt: now, updatedBy: me, updatedAt: now, }); persist(); notify(); const [y, m, d] = date.split("-").map(Number); const day = new Date(Date.UTC(y, m - 1, d)).toLocaleDateString("en-US", { timeZone: "UTC", weekday: "short", month: "short", day: "numeric" }).replace(/,/g, ""); const [hh, mm] = time.split(":").map(Number); const t12 = ((hh % 12) || 12) + ":" + String(mm).padStart(2, "0") + (hh < 12 ? " AM" : " PM"); return { ok: true, id, msg: `${serviceType} booked for ${customer || custNum} on ${day} at ${t12}.` }; }, // (sales features H2) reschedule / edit an appointment; cancel one. Service roles only. updateAppointment(id, patch) { if (!api.can("service")) return { ok: false, denied: true, msg: "Your role can't change appointments - ask a service advisor." }; const a = (state.appointments || []).find(x => x && x.id === id); if (!a) return { ok: false, msg: "Appointment not found." }; const p = Object.assign({}, patch || {}); ["id", "_cid", "createdBy", "createdAt", "roId"].forEach(k => { delete p[k]; }); if (p.date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(p.date))) return { ok: false, msg: "Pick a date." }; if (p.time !== undefined && !/^\d{2}:\d{2}$/.test(String(p.time))) return { ok: false, msg: "Pick a time." }; if (p.serviceType !== undefined && !api.svcApptTypes().includes(p.serviceType) && !api.SVC_APPT_TYPES.includes(p.serviceType)) p.serviceType = "Other"; // RC32 (L10) if (p.estHours !== undefined) p.estHours = +p.estHours || 1.0; Object.assign(a, p); if (!a._cid) a._cid = Math.random().toString(36).slice(2, 7) + Math.random().toString(36).slice(2, 7); a.updatedBy = (currentUser && currentUser.user) || ""; a.updatedAt = new Date().toISOString(); persist(); notify(); return { ok: true, msg: `Appointment for ${a.customer || a.custNum || id} updated.` }; }, cancelAppointment(id, reason) { if (!api.can("service")) return { ok: false, denied: true, msg: "Your role can't cancel appointments - ask a service advisor." }; const a = (state.appointments || []).find(x => x && x.id === id); if (!a) return { ok: false, msg: "Appointment not found." }; if (a.status === "Checked In") return { ok: false, msg: `This customer is already checked in (${a.roId || "RO open"}).` }; const now = new Date().toISOString(); const me = (currentUser && currentUser.user) || ""; a.status = "Cancelled"; a.cancelledBy = me; a.cancelledAt = now; a.cancelReason = String(reason || "").slice(0, 300); a.updatedBy = me; a.updatedAt = now; persist(); notify(); return { ok: true, msg: `Appointment for ${a.customer || a.custNum || id} cancelled.` }; }, // Assign / reassign a tech to an appointment assignTechToAppt(apptId, techId) { const a = (state.appointments || []).find(x => x.id === apptId); if (!a) return { ok: false, msg: "Appointment not found." }; a.techId = techId; persist(); notify(); const t = (state.technicians || []).find(x => x.id === techId); return { ok: true, msg: `${apptId} assigned to ${t ? t.name : techId}.` }; }, // Check an appointment in → opens a repair order on the shared ledger // (sales features H3) the RO keeps the appointment's customer and vehicle (custNum / cust / veh / // vin / serviceType / apptId), so the visit stays ONE visit and the salesperson's alert follows it. checkInAppointment(apptId) { if (!api.can("service")) return { ok: false, denied: true, msg: "Your role can't check customers in - ask a service advisor." }; const a = (state.appointments || []).find(x => x.id === apptId); if (!a) return { ok: false, msg: "Appointment not found." }; if (a.roId) return { ok: false, msg: `${apptId} already opened ${a.roId}.` }; if (a.status === "Cancelled") return { ok: false, msg: "This appointment was cancelled. Book it again first." }; const tech = (state.technicians || []).find(x => x.id === a.techId); const roId = api._nextRoId(); // rough sale estimate from est hours @ $145 door rate const laborSale = +((+a.estHours || 0) * 145).toFixed(2); const laborCost = +((+a.estHours || 0) * (tech ? tech.rate : 26)).toFixed(2); const veh = a.veh && typeof a.veh === "object" ? { vin: String(a.veh.vin || a.vin || "").toUpperCase(), yr: a.veh.yr || "", mk: a.veh.mk || "", mdl: a.veh.mdl || "", miles: a.veh.miles || "", stock: "" } : null; const vin = String((veh && veh.vin) || a.vin || "").toUpperCase(); const cust = a.cust && typeof a.cust === "object" ? { custNum: a.cust.custNum || a.custNum || "", name: a.cust.name || a.customer || "", phone: a.cust.phone || "", email: a.cust.email || "" } : (a.custNum ? { custNum: a.custNum, name: a.customer || "", phone: "", email: "" } : null); const me = (currentUser && currentUser.user) || ""; const ro = { id: roId, _cid: Math.random().toString(36).slice(2, 7) + Math.random().toString(36).slice(2, 7), apptId: a.id, customer: a.customer, vehicle: a.vehicle, vin, advisor: a.advisor, techId: a.techId, tech: tech ? tech.name : "", payType: "Customer", status: "Open", postedToGL: false, laborSale, laborCost, flagHours: 0, partsSale: 0, partsCost: 0, tax: +(laborSale * (window.jbTaxRate ? window.jbTaxRate("service") : 0.07225)).toFixed(2), total: 0, concern: a.concern, opened: _iso(new Date()), openedBy: me, }; if (a.custNum) ro.custNum = a.custNum; if (cust) ro.cust = cust; if (veh) { ro.veh = veh; if (veh.miles) ro.miles = veh.miles; } if (a.serviceType) ro.serviceType = a.serviceType; state.repairOrders.unshift(ro); a.status = "Checked In"; a.roId = roId; a.updatedBy = me; a.updatedAt = new Date().toISOString(); persist(); notify(); return { ok: true, id: roId, msg: `${a.customer} checked in — ${roId} opened${tech ? " for " + tech.name : ""}.` }; }, // (sales features H5) next RO number: 1 + the largest number in any "RO-" id of this PC's // repair orders or any appointment's roId, never below the store's first RO number (Owner Panel > Stores). Two PCs can still pick the same // number at the same moment; the sync layer re-numbers the later one (H6). _nextRoId() { const fl = Number(((window.K2BStore && window.K2BStore.options().roNumber) || {}).floor); // RC35 (brd): the store's floor let max = (Number.isInteger(fl) && fl > 0 ? fl : 1001) - 1; const take = (id) => { const m = /^RO-(\d+)$/.exec(String(id || "")); if (m) { const n = parseInt(m[1], 10); if (n > max) max = n; } }; (state.repairOrders || []).forEach(r => r && take(r.id)); (state.appointments || []).forEach(a => a && take(a.roId)); ((api._weOwes && api._weOwes.list) || []).forEach(w => w && take(w.roId)); // RC27 (dsk) fix round: a number a we-owe holds is never reused return "RO-" + (max + 1); }, // Tech clocks flagged hours onto an open RO (drives productivity) flagHoursOnRO(roId, hours) { const ro = state.repairOrders.find(r => r.id === roId); if (!ro) return { ok: false, msg: "RO not found." }; const h = +hours || 0; ro.flagHours = +((ro.flagHours || 0) + h).toFixed(1); const t = (state.technicians || []).find(x => x.id === ro.techId); if (t) { t.flaggedToday = +(t.flaggedToday + h).toFixed(1); t.flaggedMTD = +(t.flaggedMTD + h).toFixed(1); } persist(); notify(); return { ok: true, msg: `${h}h flagged to ${roId}${t ? " (" + t.name + ")" : ""}.` }; }, // Reassign a tech on an open RO assignTechToRO(roId, techId) { const ro = state.repairOrders.find(r => r.id === roId); if (!ro) return { ok: false, msg: "RO not found." }; const t = (state.technicians || []).find(x => x.id === techId); ro.techId = techId; ro.tech = t ? t.name : techId; api.recalcRO(roId); persist(); notify(); return { ok: true, msg: `${roId} dispatched to ${t ? t.name : techId}.` }; }, // ── Repair-order line editing (drives the GL on close) ─────────── // An RO carries two line arrays the editor maintains: // ops: [{ id, op, desc, hrs, lab, status }] (labor) // partsLines: [{ id, part, desc, qty, cost, sale }] (parts) // recalcRO rolls those up into the laborSale/partsSale/cost/tax // fields the close-RO cascade reads, so every edit is reflected in // the journal entry that ultimately posts. recalcRO(roId) { const ro = state.repairOrders.find(r => r.id === roId); if (!ro) return { ok: false, msg: "RO not found." }; // RC20 (fops): a closed RO keeps the totals the server committed — nothing is re-figured on a station. if (ro.status === "Closed") return { ok: true, committed: true }; // ONE calculation (FopsCalc = the server's): DECLINED jobs (and their parts / sublets / fees) are excluded; tax once on the // customer-pay taxable work with the shared rate (serviceSettings.tax); the station's own gear rate is no longer used. const F = window.FopsCalc; const t = F.roTotals(ro, api.fopsTaxConfig(), api._fopsCalcCtx()); const declined = api.roDeclinedJobIds(ro); const ops = (ro.ops || []).filter(o => !declined.has(String(o.id))); ro.laborSale = t.laborCents / 100; ro.flagHours = +ops.reduce((s, o) => s + (+o.hrs || 0), 0).toFixed(1); ro.partsSale = t.partsCents / 100; ro.subletSale = t.subletCents / 100; ro.feesSale = t.feesCents / 100; if (api.fopsSeesShopCost()) { // decision 12: only the roles that receive pay rates figure the cost (others keep the stored figures — the server writes them back) ro.laborCost = t.costs.laborCents / 100; ro.partsCost = t.costs.partsCents / 100; ro.subletCost = t.costs.subletCents / 100; } ro.taxBase = t.taxableCents / 100; ro.tax = t.taxCents / 100; ro.total = t.totalCents / 100; return { ok: true }; }, // Materialize editable line arrays for a legacy/summary-only RO so the // editor can show & edit lines. Idempotent — only runs once per RO. hydrateROLines(roId) { const ro = state.repairOrders.find(r => r.id === roId); if (!ro) return { ok: false }; if (ro.status === "Closed") return { ok: true, closed: true }; // RC20: viewing a closed RO never rewrites it (its lines are history; the server refuses a change) if (ro.ops && ro.partsLines) return { ok: true }; if (!ro.ops) { ro.ops = ro.laborSale > 0 ? [{ id: Date.now() + 1, op: "LABOR", desc: ro.concern || "Labor — work performed", hrs: ro.flagHours || +(ro.laborSale / 145).toFixed(1), lab: ro.laborSale, status: ro.status === "Closed" ? "DONE" : "ACTIVE" }] : []; } if (!ro.partsLines) { ro.partsLines = ro.partsSale > 0 ? [{ id: Date.now() + 2, part: "PARTS", desc: "Parts — per estimate", qty: 1, cost: ro.partsCost || 0, sale: ro.partsSale }] : []; } persist(); return { ok: true }; }, createRO(detail) { const roId = api._nextRoId(); // (sales features H4) next free number + creation nonce const tech = (state.technicians || []).find(x => x.id === (detail.techId || "")); state.repairOrders.unshift({ id: roId, _cid: Math.random().toString(36).slice(2, 7) + Math.random().toString(36).slice(2, 7), customer: detail.customer || "", vehicle: detail.vehicle || "", vin: detail.vin || "", advisor: detail.advisor || "Mark Service", techId: detail.techId || "", tech: tech ? tech.name : "", payType: detail.payType || "Customer", status: "Open", postedToGL: false, concern: detail.concern || "", ops: detail.ops || [], partsLines: detail.partsLines || [], laborSale: 0, laborCost: 0, partsSale: 0, partsCost: 0, flagHours: 0, tax: 0, total: 0, internalStock: detail.internalStock || "", warrantyClaim: detail.payType === "Warranty" ? ("WC-" + String(Date.now()).slice(-6)) : undefined, opened: _iso(new Date()), // RC32 (R6 / R7): promise time + transport (an RO without them shows none) ...(detail.promise ? { promise: String(detail.promise).slice(0, 40) } : {}), ...(detail.transport ? { transport: String(detail.transport).slice(0, 24) } : {}), }); api.recalcRO(roId); persist(); notify(); return { ok: true, msg: `${roId} opened for ${detail.customer || "new customer"}.`, id: roId }; }, updateRODetail(roId, patch) { const ro = state.repairOrders.find(r => r.id === roId); if (!ro) return { ok: false, msg: "RO not found." }; Object.assign(ro, patch); if (patch.techId !== undefined) { const t = (state.technicians || []).find(x => x.id === patch.techId); ro.tech = t ? t.name : patch.techId; } if (patch.payType === "Warranty" && !ro.warrantyClaim) ro.warrantyClaim = "WC-" + String(Date.now()).slice(-6); api.recalcRO(roId); persist(); notify(); return { ok: true, msg: `${roId} updated.` }; }, addROOp(roId, op) { const ro = state.repairOrders.find(r => r.id === roId); if (!ro) return { ok: false, msg: "RO not found." }; ro.ops = ro.ops || []; ro.ops.push({ id: "OP-" + Date.now().toString(36).toUpperCase() + Math.random().toString(36).slice(2, 6).toUpperCase(), op: op.op || "LABOR", desc: op.desc || "", hrs: +op.hrs || 0, lab: +op.lab || 0, status: op.status || "PEND", complaint: op.complaint || op.desc || "", cause: op.cause || "", correction: op.correction || "", techNotes: op.techNotes || "", approval: op.approval || "pending", // job fields (RO jobs = ops): per-job tech, pay type, pricing rule, template link ...(op.id != null ? { id: op.id } : {}), ...(op.techId !== undefined ? { techId: op.techId } : {}), ...(op.payType ? { payType: op.payType } : {}), ...(op.stdJobId ? { stdJobId: op.stdJobId } : {}), ...(op.rateMode ? { rateMode: op.rateMode, rate: +op.rate || 0, flat: +op.flat || 0 } : {}), ...(op.custDesc !== undefined ? { custDesc: op.custDesc } : {}) }); api.recalcRO(roId); persist(); notify(); return { ok: true, msg: `Op ${op.op || ""} added to ${roId}.`, id: ro.ops[ro.ops.length - 1].id }; }, updateROOp(roId, opId, patch) { const ro = state.repairOrders.find(r => r.id === roId); if (!ro) return { ok: false, msg: "RO not found." }; ro.ops = (ro.ops || []).map(o => o.id === opId ? { ...o, ...patch } : o); api.recalcRO(roId); persist(); notify(); return { ok: true }; }, removeROOp(roId, opId) { const ro = state.repairOrders.find(r => r.id === roId); if (!ro) return { ok: false, msg: "RO not found." }; ro.ops = (ro.ops || []).filter(o => o.id !== opId); api.recalcRO(roId); persist(); notify(); return { ok: true, msg: "Op removed." }; }, addROPart(roId, partNo, qty) { const ro = state.repairOrders.find(r => r.id === roId); if (!ro) return { ok: false, msg: "RO not found." }; const p = state.parts.find(x => x.part === partNo); if (!p) return { ok: false, msg: "Part not found." }; ro.partsLines = ro.partsLines || []; const ex = ro.partsLines.find(l => l.part === partNo); if (ex) ex.qty += (qty || 1); else ro.partsLines.push({ id: "PL-" + Date.now().toString(36).toUpperCase() + Math.random().toString(36).slice(2, 6).toUpperCase(), part: p.part, desc: p.desc, qty: qty || 1, cost: p.cost, sale: p.list, ...(+p.core > 0 ? { core: p.core } : {}) }); // RC33 (mpx, B14): the part's core charge — a customer-pay line collects it at the close // soft-commit the part so availability reflects it p.committed = (p.committed || 0) + (qty || 1); api.recalcRO(roId); persist(); notify(); return { ok: true, msg: `${qty || 1}× ${partNo} added to ${roId}.` }; }, setROPartQty(roId, lineId, qty) { const ro = state.repairOrders.find(r => r.id === roId); if (!ro) return { ok: false, msg: "RO not found." }; const line = (ro.partsLines || []).find(l => l.id === lineId); // RC28 fix round (gate 2a class): a dispensed bulk-oil line is Dispense's — its quantity left the tank (the server refuses the change too) if (line && line.bulk === true) return { ok: false, msg: "This bulk oil line was dispensed from the tank — its quantity can't be changed here. Set its price to $0.00 if the customer is not charged; the oil's cost comes out of the bulk account when the RO closes." }; if (line) { const p = state.parts.find(x => x.part === line.part); if (p) p.committed = Math.max(0, (p.committed || 0) - line.qty + Math.max(0, qty)); line.qty = Math.max(0, qty); ro.partsLines = ro.partsLines.filter(l => l.qty > 0); } api.recalcRO(roId); persist(); notify(); return { ok: true }; }, removeROPart(roId, lineId) { { const ro0 = (state.repairOrders || []).find(r => r.id === roId); const l0 = ro0 && (ro0.partsLines || []).find(l => l.id === lineId); // RC28 fix round (gate 2a class): a dispensed bulk-oil line stays on the RO (the server refuses taking it off too) if (l0 && l0.bulk === true) return { ok: false, msg: "This bulk oil line was dispensed from the tank, so it stays on the RO — the oil's cost comes out of the bulk account when the RO closes. Set its price to $0.00 if the customer is not charged." }; } const ro = state.repairOrders.find(r => r.id === roId); if (!ro) return { ok: false, msg: "RO not found." }; const line = (ro.partsLines || []).find(l => l.id === lineId); if (line) { const p = state.parts.find(x => x.part === line.part); if (p) p.committed = Math.max(0, (p.committed || 0) - line.qty); } ro.partsLines = (ro.partsLines || []).filter(l => l.id !== lineId); api.recalcRO(roId); persist(); notify(); return { ok: true, msg: "Part removed." }; }, // ════════════════════════════════════════════════════════════════ // RO WORKFLOW — stage lifecycle, three C's, line authorization, and // e-sign. The advisor writes up the concern, schedules/dispatches a // tech, the tech diagnoses (cause) and proposes work, the customer // authorizes (per line + an overall e-sign), the tech corrects, the // advisor does a final check and cashes out. `stage` drives the // pipeline; `status` (Open/Closed) still governs the GL close. // ════════════════════════════════════════════════════════════════ RO_STAGES: [ "Write-Up", "Scheduled", "Checked In", "Dispatched", "In Diagnosis", "Estimate", "Awaiting Approval", "Authorized", "In Progress", "Complete", "Final Check", "Cashed Out", ], roStageIndex(ro) { const i = api.RO_STAGES.indexOf(ro.stage || "Write-Up"); return i < 0 ? 0 : i; }, setROStage(roId, stage) { const ro = state.repairOrders.find(r => r.id === roId); if (!ro) return { ok: false, msg: "RO not found." }; if (!api.RO_STAGES.includes(stage)) return { ok: false, msg: "Unknown stage." }; ro.stage = stage; ro.stageHist = ro.stageHist || {}; ro.stageHist[stage] = _iso(new Date()); logAudit("RO Stage", `${roId} → ${stage}`); persist(); notify(); return { ok: true, msg: `${roId} moved to ${stage}.` }; }, advanceRO(roId) { const ro = state.repairOrders.find(r => r.id === roId); if (!ro) return { ok: false, msg: "RO not found." }; const i = api.roStageIndex(ro); // gate: can't pass Authorized until the estimate is authorized if (api.RO_STAGES[i] === "Awaiting Approval" && !ro.authorization) { return { ok: false, msg: "Estimate must be authorized by the customer first." }; } const next = api.RO_STAGES[Math.min(i + 1, api.RO_STAGES.length - 1)]; return api.setROStage(roId, next); }, // Tech diagnosis / 3 C's live on each op; updateROOp already spreads the // patch, so this is a thin labelled wrapper used by the editor. setOpDiagnosis(roId, opId, patch) { return api.updateROOp(roId, opId, patch); }, // Customer approves or declines a single proposed line. setOpApproval(roId, opId, decision) { const ro = state.repairOrders.find(r => r.id === roId); if (!ro) return { ok: false, msg: "RO not found." }; const op = (ro.ops || []).find(o => o.id === opId); if (!op) return { ok: false, msg: "Line not found." }; op.approval = decision; // 'approved' | 'declined' | 'pending' op.status = decision === "declined" ? "HOLD" : (op.status === "HOLD" ? "PEND" : op.status); api.recalcRO(roId); // declined jobs drop out of totals / tax / GL persist(); notify(); return { ok: true, msg: `Line ${decision}.` }; }, roEstimateTotals(roId) { const ro = api._roForRead(roId); // RC32: look only if (!ro) return { approved: 0, declined: 0, pending: 0 }; const ops = ro.ops || []; const sum = (f) => ops.filter(f).reduce((s, o) => s + (+o.lab || 0), 0); return { approved: +sum(o => o.approval === "approved").toFixed(2), declined: +sum(o => o.approval === "declined").toFixed(2), pending: +sum(o => !o.approval || o.approval === "pending").toFixed(2), }; }, // RC32 (Q1 / X4): the whole estimate of the live jobs (DMS.roMoney) — what the card / e-sign page show and authorizeRO saves roAuthEstimate(roOrId) { const ro = api._roForRead(roOrId); if (!ro) return null; const m = api.roMoney(ro) || {}; const declined = api.roDeclinedJobIds ? api.roDeclinedJobIds(ro) : new Set(); const c2 = (n) => +(+n || 0).toFixed(2); return { labor: c2(m.labor), parts: c2(m.parts), sublet: c2(m.sublet), fees: c2(m.fees), tax: c2(m.tax), total: c2(m.total), laborApproved: api.roEstimateTotals(ro).approved, jobIds: (ro.ops || []).filter(o => o && o.approval !== "declined" && !declined.has(String(o.id))).map(o => String(o.id)) }; }, // Overall authorization gate. method: 'esign' | 'phone' | 'in-person'. // RC32: + authorization.estimate (what the customer saw; shown = the signing screen's, else X4); `amount` = approved labor as today. // An OK of the WHOLE estimate approves its still-pending jobs (as setOpApproval); "labor" keeps RC31's rule (docs/k2b/ro-screens.md) authorizeRO(roId, info) { const ro = state.repairOrders.find(r => r.id === roId); if (!ro) return { ok: false, msg: "RO not found." }; info = info || {}; let est = null; try { const e = api.roAuthEstimate(ro); let shown = info.shown === "labor" || info.shown === "whole" ? info.shown : null; if (!shown) { try { shown = api.svcSettings ? api.svcSettings().ro.texts.approvalAmount : "labor"; } catch (x) { shown = "labor"; } } if (e) est = { labor: e.labor, parts: e.parts, sublet: e.sublet, fees: e.fees, tax: e.tax, total: e.total, shown: shown === "whole" ? "whole" : "labor", jobIds: e.jobIds }; } catch (x) { est = null; } const okJobs = est && est.shown === "whole" ? new Set(est.jobIds) : new Set(); let nOk = 0; (ro.ops || []).forEach(o => { if (!o || !okJobs.has(String(o.id)) || o.approval === "approved" || o.approval === "declined") return; o.approval = "approved"; nOk++; if (o.status === "HOLD" && !o.holdReason) o.status = "PEND"; // a hold with a reason stays }); if (nOk && api.recalcRO) api.recalcRO(roId); ro.authorization = { method: info.method || "in-person", by: info.by || ro.customer || "", signature: info.signature || "", signedName: info.signedName || "", at: _iso(new Date()), amount: api.roEstimateTotals(roId).approved, ...(est ? { estimate: est } : {}), }; if (api.roStageIndex(ro) < api.RO_STAGES.indexOf("Authorized")) api.setROStage(roId, "Authorized"); logAudit("RO Authorized", `${roId} via ${ro.authorization.method} by ${ro.authorization.by}${nOk ? ` (${nOk} job${nOk === 1 ? "" : "s"} approved with the OK)` : ""}`); persist(); notify(); return { ok: true, approvedJobs: nOk, msg: `${roId} authorized (${ro.authorization.method}).` }; }, // E-SIGN — generate a request token the advisor "sends" by text/email. requestROSignature(roId, info) { const ro = state.repairOrders.find(r => r.id === roId); if (!ro) return { ok: false, msg: "RO not found." }; info = info || {}; const token = "S" + Math.random().toString(36).slice(2, 8).toUpperCase(); ro.signRequest = { token, via: info.via || "text", to: info.to || "", sentAt: _iso(new Date()), status: "sent" }; logAudit("RO Sign Request", `${roId} via ${ro.signRequest.via} to ${ro.signRequest.to}`); persist(); notify(); return { ok: true, token, msg: `Sign request ${info.via === "email" ? "emailed" : "texted"} to ${info.to || "customer"}.` }; }, findROBySignToken(token) { const ro = (state.repairOrders || []).find(r => r.signRequest && r.signRequest.token === token); return ro || null; }, // Customer submits their signature against the token → authorizes the RO. submitROSignature(token, sig) { const ro = api.findROBySignToken(token); if (!ro) return { ok: false, msg: "Invalid or expired signing link." }; sig = sig || {}; ro.signRequest.status = "signed"; ro.signRequest.signedAt = _iso(new Date()); api.authorizeRO(ro.id, { method: "esign", by: sig.name || ro.customer, signedName: sig.name || ro.customer, signature: sig.signature || "", shown: sig.shown }); // RC32: + what it showed return { ok: true, msg: "Repair authorization signed. Thank you." }; }, // ════════════════════════════════════════════════════════════════ // DEAL E-SIGN — purchase-document signing for vehicle deals. // BOTH a mobile number AND an email are required before a sign // request can go out (two-channel identity for purchase docs). // ════════════════════════════════════════════════════════════════ requestDealSignature(dealId, info) { const d = (state.deals || []).find(x => x.id === dealId); if (!d) return { ok: false, msg: "Deal not found." }; info = info || {}; const phone = String(info.phone || "").replace(/[^0-9]/g, ""); const email = String(info.email || "").trim(); if (phone.length < 10) return { ok: false, msg: "A valid mobile number is required for e-signing." }; if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return { ok: false, msg: "A valid email address is required for e-signing." }; const token = "D" + Math.random().toString(36).slice(2, 8).toUpperCase(); d.signRequest = { token, phone, email, sentAt: _iso(new Date()), status: "sent" }; logAudit("Deal Sign Request", `${dealId} → text ${phone} + email ${email}`); persist(); notify(); return { ok: true, token, msg: `E-sign request sent by text (${phone}) and email (${email}).` }; }, findDealBySignToken(token) { return (state.deals || []).find(d => d.signRequest && d.signRequest.token === token) || null; }, submitDealSignature(token, sig) { const d = api.findDealBySignToken(token); if (!d) return { ok: false, msg: "Invalid or expired signing link." }; sig = sig || {}; d.signRequest.status = "signed"; d.signRequest.signedAt = _iso(new Date()); d.esign = { signature: sig.signature || "", signedName: sig.name || d.buyer || "", at: _iso(new Date()), phone: d.signRequest.phone, email: d.signRequest.email, docs: Array.isArray(sig.docs) && sig.docs.length ? sig.docs : ["Vehicle Buyer's Order"] }; // Land the signature on the printed Buyer's Order (form-data merge). if (window.saveJbForm) window.saveJbForm("buyersOrder", { esignSignature: d.esign.signature, esignName: d.esign.signedName, esignAt: d.esign.at }); logAudit("Deal E-Signed", `${d.id} by ${d.esign.signedName} — ${d.esign.docs.length} document(s)`); persist(); notify(); return { ok: true, msg: "Purchase documents signed. Thank you." }; }, // ════════════════════════════════════════════════════════════════ // ROUTEONE — F&I credit-application gateway. Credentials live in // Settings → Integrations. "Send to RouteOne" packages the deal + // credit app and tracks the submission on the deal (d.routeOne). // The live wire transfer runs through your server tier with the // saved credentials; here the submission and decision tracking are // fully functional so the F&I workflow is complete. // ════════════════════════════════════════════════════════════════ // Non-secret RouteOne settings only (dealer id, endpoint). Passwords / API // keys live ONLY in the server's encrypted store (Settings → Integrations). _stripSecrets(o) { const c = { ...(o || {}) }; Object.keys(c).forEach(k => { if (/pass|secret|token|api.?key|^key$/i.test(k)) delete c[k]; }); return c; }, routeOneConfig() { try { const raw = JSON.parse(localStorage.getItem("jbrews-routeone-cfg") || "{}"); const clean = api._stripSecrets(raw); if (Object.keys(clean).length !== Object.keys(raw).length) localStorage.setItem("jbrews-routeone-cfg", JSON.stringify(clean)); // purge old secrets return clean; } catch (e) { return {}; } }, setRouteOneConfig(patch) { try { const next = api._stripSecrets({ ...api.routeOneConfig(), ...patch }); localStorage.setItem("jbrews-routeone-cfg", JSON.stringify(next)); } catch (e) {} notify(); return { ok: true, msg: "RouteOne settings saved." }; }, sendToRouteOne(dealId) { const d = (state.deals || []).find(x => x.id === dealId); if (!d) return { ok: false, msg: "Deal not found." }; const cfg = api.routeOneConfig(); if (!cfg.dealerId || !cfg.user) return { ok: false, msg: "Add your RouteOne Dealer ID and username first — Settings → Integrations → RouteOne." }; if (!d.buyer) return { ok: false, msg: "Deal needs a buyer before it can be submitted." }; const ref = "R1-" + String(Date.now()).slice(-8); d.routeOne = { ref, status: "Submitted", sentAt: _iso(new Date()), lender: d.lender || "", amount: +d.amtFinanced || 0, by: cfg.user, dealerId: cfg.dealerId }; logAudit("RouteOne Submission", `${dealId} → RouteOne ref ${ref}${d.lender ? " (" + d.lender + ")" : ""}`); persist(); notify(); return { ok: true, ref, msg: `${dealId} sent to RouteOne — reference ${ref}. Lender decisions post back to this deal.` }; }, setRouteOneDecision(dealId, decision, note) { const d = (state.deals || []).find(x => x.id === dealId); if (!d || !d.routeOne) return { ok: false, msg: "No RouteOne submission on this deal." }; d.routeOne.status = decision; // Approved | Conditioned | Declined d.routeOne.decision = decision; d.routeOne.note = note || ""; d.routeOne.decidedAt = _iso(new Date()); logAudit("RouteOne Decision", `${dealId} — ${decision}${note ? " · " + note : ""}`); persist(); notify(); return { ok: true, msg: `RouteOne: ${dealId} ${decision.toLowerCase()}.` }; }, // ════════════════════════════════════════════════════════════════ // ACCOUNTING OFFICE — A/P, bank rec, month-end // ════════════════════════════════════════════════════════════════ // RC33 (mpx): a SERVER command now — MoneyCore.jsx.txt (loaded after this file) replaces it; this stub only answers when that file is missing payInvoice(inv) { return moneyMissing("Paying a bill"); }, // RC33 (mpx): a SERVER command now — MoneyCore.jsx.txt (loaded after this file) replaces it; this stub only answers when that file is missing addPayable(rec) { return moneyMissing("Entering a bill"); }, // ════════════════════════════════════════════════════════════════ // CHECK WRITING — write a check, post DR target / CR cash (202), // record it in the check register, and (if tied to an open payable) // mark that payable Paid. Everything flows to the GL. // ════════════════════════════════════════════════════════════════ // ── Vendor master ────────────────────────────────────────────── // ── Bulk oil / fluids ────────────────────────────────────────── // Buy oil by the drum/tanker (payable + DR 247 Gas-Oil-Grease inventory), // dispense to ROs by the quart (parts line → sale/cost flow through the // normal RO close cascade), track levels against reorder points. upsertBulkFluid(rec) { const list = api.bulkFluids(); const f = { id: rec.id || ("BF-" + Date.now().toString(36).slice(-5).toUpperCase()), name: (rec.name || "").trim(), uom: rec.uom || "qt", onHand: +rec.onHand || 0, capacity: +rec.capacity || 0, reorderAt: +rec.reorderAt || 0, cost: +rec.cost || 0, sale: +rec.sale || 0, vendor: rec.vendor || "", tank: rec.tank || "", }; if (!f.name) return { ok: false, msg: "Fluid name is required." }; const i = list.findIndex(x => x.id === f.id); // RC26 (inv): an existing tank's level and cost are the server's (Receive delivery / Dispense) — never changed by editing the tank let kept = ""; if (i >= 0 && ((+list[i].onHand || 0) !== f.onHand || (+list[i].cost || 0) !== f.cost)) { kept = ` Level and cost kept at ${list[i].onHand} ${list[i].uom || "qt"} / $${(+list[i].cost || 0).toFixed(2)} — they change only through Receive delivery and Dispense.`; f.onHand = +list[i].onHand || 0; f.cost = +list[i].cost || 0; } if (f.onHand < 0) return { ok: false, msg: "A tank level can never be below zero." }; if (i >= 0) list[i] = { ...list[i], ...f }; else list.unshift(f); logAudit("Bulk Fluid " + (i >= 0 ? "Updated" : "Added"), `${f.name} (${f.tank || "tank"})`); persist(); notify(); return { ok: true, msg: `${f.name} saved.${kept}` }; }, removeBulkFluid(id) { const t = api.bulkFluids().find(f => f.id === id); if (t && (+t.onHand || 0) > 0) return { ok: false, msg: `${t.name} still has ${t.onHand} ${t.uom || "qt"} in it — a tank with fluid in it can't be removed (its value is on the books). Dispense it to zero first, or ask the office.` }; state.bulkFluids = api.bulkFluids().filter(f => f.id !== id); persist(); notify(); return { ok: true, msg: "Fluid removed." }; }, // RC26 (inv): a bulk delivery is a server command — the tank level up, the cost, ONE vendor bill (A/P 300) and OIL- (DR 247 — or 244 when the // owner picked it — / CR 300) together. Dispense to an RO is a server command too: the tank down, the RO's parts line in (the RO close posts it). receiveBulkOil(rec) { rec = rec || {}; const f = api.bulkFluids().find(x => x.id === rec.fluidId); if (!f) return Promise.resolve({ ok: false, msg: "Fluid not found." }); return api._partsCommand("/parts/bulk/receive", { fluidId: f.id, qty: rec.qty == null ? "" : String(rec.qty), unitCost: rec.unitCost == null || rec.unitCost === "" ? undefined : String(rec.unitCost), vendor: rec.vendor || "", invoice: rec.invoice || "" }, "oil", "the delivery into " + f.name); }, async dispenseBulkOil(roId, fluidId, qts) { const ro = (state.repairOrders || []).find(r => r.id === roId); if (!ro) return { ok: false, msg: "RO not found." }; if (ro.status === "Closed") return { ok: false, msg: "RO is closed." }; const f = api.bulkFluids().find(x => x.id === fluidId); if (!f) return { ok: false, msg: "Fluid not found." }; const r = await api._partsCommand("/parts/bulk/dispense", { roId, fluidId: f.id, qty: String(qts) }, "bfd", "the dispense to " + roId); if (r.ok && api.recalcRO) { try { api.recalcRO(roId); } catch (e) {} } return r; }, bulkOilStatus() { return api.bulkFluids().map(f => ({ ...f, pct: f.capacity ? Math.round((f.onHand / f.capacity) * 100) : 0, low: f.onHand <= f.reorderAt, suggestedOrder: Math.max(0, +(f.capacity - f.onHand).toFixed(0)) })); }, upsertVendor(rec) { state.vendors = state.vendors || []; const v = { id: rec.id || ("V-" + Date.now().toString(36).slice(-6)), name: (rec.name || "").trim(), contact: rec.contact || "", phone: rec.phone || "", email: rec.email || "", address: rec.address || "", acctNo: rec.acctNo || "", terms: rec.terms || "Net 30", defaultAcct: rec.defaultAcct || "300", creditLimit: +rec.creditLimit || 0, type: rec.type || "Trade", active: rec.active !== false, notes: rec.notes || "", }; if (!v.name) return { ok: false, msg: "Vendor name is required." }; const i = state.vendors.findIndex(x => x.id === v.id); if (i >= 0) state.vendors[i] = { ...state.vendors[i], ...v }; else state.vendors.unshift(v); logAudit("Vendor " + (i >= 0 ? "Updated" : "Added"), `${v.name} (${v.terms}, acct ${v.defaultAcct})`); persist(); notify(); return { ok: true, id: v.id, msg: `Vendor ${v.name} saved.` }; }, removeVendor(id) { state.vendors = (state.vendors || []).filter(v => v.id !== id); persist(); notify(); return { ok: true, msg: "Vendor removed." }; }, vendorBalance(id) { const v = (state.vendors || []).find(x => x.id === id); if (!v) return 0; return (state.payables || []).filter(p => p.status === "Open" && (p.vendorId === id || p.vendor === v.name)) .reduce((s, p) => s + (+p.amount || 0), 0); }, // Full account view for one vendor: open + paid payables, every check // written to them, and totals (open balance, paid YTD, credit remaining). vendorHistory(id) { const v = (state.vendors || []).find(x => x.id === id); if (!v) return null; const match = (rec) => rec.vendorId === id || String(rec.vendor || rec.payee || "").trim().toUpperCase() === v.name.trim().toUpperCase(); const open = (state.payables || []).filter(p => p.status === "Open" && match(p)); const paid = (state.payables || []).filter(p => p.status !== "Open" && match(p)); const checks = (state.checks || []).filter(c => match(c) && c.status !== "Void"); const voided = (state.checks || []).filter(c => match(c) && c.status === "Void"); const yr = new Date().getFullYear(); const inYr = (d) => { const t = new Date(d); return !isNaN(t) && t.getFullYear() === yr; }; const openBal = +open.reduce((s, p) => s + (+p.amount || 0), 0).toFixed(2); const paidYtd = +checks.filter(c => inYr(c.date)).reduce((s, c) => s + (+c.amount || 0), 0).toFixed(2); return { vendor: v, open, paid, checks, voided, openBal, paidYtd, creditRemaining: v.creditLimit ? +(v.creditLimit - openBal).toFixed(2) : null, overLimit: !!v.creditLimit && openBal > v.creditLimit }; }, // ── Check configuration / sequencing ─────────────────────────── // One shared config (synced): company lines, MICR font, and one record per // bank account — routing / account #, check stock, next check number. setCheckConfig(patch) { const cfg = _checkCfg(); const p = { ...(patch || {}) }; // legacy callers: { nextNum, bankAcct, bankName } edit the default account if (p.nextNum != null || p.bankAcct != null || p.bankName != null) { const a = cfg.accounts.find(x => x.id === cfg.defaultId) || cfg.accounts[0]; if (p.nextNum != null) a.nextNum = Math.max(1, parseInt(p.nextNum, 10) || 1); if (p.bankAcct != null) a.glAcct = String(p.bankAcct); if (p.bankName != null) a.label = String(p.bankName); delete p.nextNum; delete p.bankAcct; delete p.bankName; } ["company", "micrFont", "micrFontName", "defaultId"].forEach(k => { if (p[k] !== undefined) cfg[k] = p[k]; }); state.checkConfig = _checkCfgStore(cfg); const d = _checkCfg(); logAudit("Check Setup Changed", `default ${d.label} · next #${d.nextNum}`); persist(); notify(); return { ok: true, msg: `Check setup saved — next check #${d.nextNum}.` }; }, // Add / edit one bank account. Routing # must pass the ABA check digit. saveCheckAccount(rec) { const r = rec || {}; const cfg = _checkCfg(); const routing = String(r.routing || "").replace(/\D/g, ""); const account = String(r.account || "").replace(/[^0-9-]/g, ""); if (routing && !_abaOk(routing)) return { ok: false, msg: "Routing number " + routing + " is not valid — check the 9 digits against a check from this bank." }; if (account && account.replace(/\D/g, "").length < 4) return { ok: false, msg: "Account number looks too short." }; const nextNum = Math.max(1, parseInt(r.nextNum, 10) || 1); const id = r.id || ("BK" + Date.now().toString(36).toUpperCase()); const used = (state.checks || []).filter(c => (c.bankId || cfg.defaultId) === id).reduce((m, c) => Math.max(m, parseInt(c.num, 10) || 0), 0); const acct = { id, label: String(r.label || "Operating").slice(0, 40), glAcct: String(r.glAcct || "202").trim(), bankName: String(r.bankName || "").slice(0, 50), bankLine2: String(r.bankLine2 || "").slice(0, 50), fractional: String(r.fractional || "").slice(0, 20), routing, account, nextNum, stock: r.stock === "3up" ? "3up" : "top", micrFormat: r.micrFormat === "personal" ? "personal" : "business", sigLine: r.sigLine !== false, sigLines: r.sigLines === 2 ? 2 : 1, sigOver: String(r.sigOver || "").slice(0, 40), offsetX: +(+r.offsetX || 0).toFixed(3), offsetY: +(+r.offsetY || 0).toFixed(3), micrOffsetY: +(+r.micrOffsetY || 0).toFixed(3), voidAfterDays: Math.max(0, parseInt(r.voidAfterDays, 10) || 0), }; const ix = cfg.accounts.findIndex(x => x.id === id); if (ix >= 0) cfg.accounts[ix] = acct; else cfg.accounts.push(acct); if (!cfg.defaultId || cfg.accounts.length === 1) cfg.defaultId = id; state.checkConfig = _checkCfgStore(cfg); logAudit("Check Account Saved", `${acct.label} · GL ${acct.glAcct} · routing ${routing ? "…" + routing.slice(-4) : "—"} · acct ${account ? "…" + account.slice(-4) : "—"} · next #${nextNum} · ${acct.stock === "3up" ? "3 per page" : "check on top"}`); persist(); notify(); const warn = used && nextNum <= used ? ` Next # ${nextNum} is at or below #${used}, already written on this account.` : ""; return { ok: true, id, msg: `${acct.label} saved — next check #${nextNum}.${warn}` }; }, removeCheckAccount(id) { const cfg = _checkCfg(); if (cfg.accounts.length <= 1) return { ok: false, msg: "Keep at least one bank account." }; if ((state.checks || []).some(c => c.bankId === id && c.status === "Outstanding")) return { ok: false, msg: "This account has outstanding checks — clear or void them first." }; cfg.accounts = cfg.accounts.filter(x => x.id !== id); if (cfg.defaultId === id) cfg.defaultId = cfg.accounts[0].id; state.checkConfig = _checkCfgStore(cfg); logAudit("Check Account Removed", id); persist(); notify(); return { ok: true, msg: "Bank account removed." }; }, abaValid(r) { return _abaOk(String(r || "").replace(/\D/g, "")); }, // PREVIEW only: this station's copy of the setup. The number printed on a check is the one the server reserves // at post time (reserveCheckNum / postCheck) — two stations can no longer print the same number. peekNextCheckNum(bankId) { const cfg = _checkCfg(); const a = cfg.accounts.find(x => x.id === (bankId || cfg.defaultId)) || cfg.accounts[0]; return String(a.nextNum); }, // ── Shared check numbers (server-reserved) ───────────────────── CHECK_OFFLINE_MSG: "The server is not reachable — check numbers come from the server so two stations never print the same number. Reconnect and try again.", _checkApiMsg(e) { const st = e && e.status; if (st === 401) return "Your sign-in has expired — sign in again to write checks."; return (st === 409 || st === 403 || st === 400) && e.message ? e.message : api.CHECK_OFFLINE_MSG; }, // What is wrong with a check before any number is reserved (so a refused draft never burns a number). checkDraftProblem(rec) { const r = rec || {}; const amount = +(+r.amount || 0).toFixed(2); if (!r.payee || !String(r.payee).trim()) return "Payee is required."; if (amount <= 0) return "Check amount must be greater than zero."; if (amount >= 10000000) return "Check amount is too large."; return null; }, // Reserve the next number of a bank account on the server (atomic — one station at a time). async reserveCheckNum(bankId) { const id = bankId || _checkCfg().defaultId; if (!AUTH_TOKEN || __offlineByChoice) return { ok: false, msg: api.CHECK_OFFLINE_MSG, bankId: id }; try { const r = await apiPost("/checks/next", { bankId: id }); return { ok: true, num: String(r.num), cid: r.cid ? String(r.cid) : "", bankId: id }; } catch (e) { return { ok: false, msg: api._checkApiMsg(e), bankId: id }; } }, // Give a reservation back when the check was not written after all (the number stays on record as released). async releaseCheckNum(bankId, num) { if (!AUTH_TOKEN || __offlineByChoice) return { ok: false, msg: api.CHECK_OFFLINE_MSG }; try { await apiPost("/checks/release", { bankId: bankId || _checkCfg().defaultId, num: String(num) }); return { ok: true }; } catch (e) { return { ok: false, msg: api._checkApiMsg(e) }; } }, // The server's next number for a bank account (preview only — nothing reserved). async peekServerCheckNum(bankId) { const id = bankId || _checkCfg().defaultId; if (!AUTH_TOKEN || __offlineByChoice) return { ok: false, msg: api.CHECK_OFFLINE_MSG }; try { const r = await apiGet("/checks/peek?bankId=" + encodeURIComponent(id)); return { ok: true, num: String(r.num) }; } catch (e) { return { ok: false, msg: api._checkApiMsg(e) }; } }, // Check Setup: the next number lives on the server (office roles). Refused offline or when that number is in use. async setCheckCounter(bankId, num) { if (!AUTH_TOKEN || __offlineByChoice) return { ok: false, msg: api.CHECK_OFFLINE_MSG }; try { const r = await apiPost("/checks/set-next", { bankId, num: String(num) }); return { ok: true, num: String(r.num) }; } catch (e) { return { ok: false, msg: api._checkApiMsg(e) }; } }, // Save a bank account; a changed "next check #" moves the server counter FIRST (so every station follows). async saveCheckAccountShared(rec) { const r = rec || {}; const cfg = _checkCfg(); const id = r.id || ("BK" + Date.now().toString(36).toUpperCase()); const cur = cfg.accounts.find(x => x.id === id); const nextNum = Math.max(1, parseInt(r.nextNum, 10) || 1); if (!cur || cur.nextNum !== nextNum) { const s = await api.setCheckCounter(id, nextNum); if (!s.ok) return s; } return api.saveCheckAccount({ ...r, id }); }, // Reserve a number and write the check in one go (callers without their own screen, e.g. payroll deduction checks). async postCheck(rec) { const r = rec || {}; const pre = api.checkDraftProblem(r); if (pre) return { ok: false, msg: pre }; const res = await api.reserveCheckNum(r.bankId); if (!res.ok) return res; const out = await api.writeCheck({ ...r, num: res.num, cid: res.cid }); if (!out.ok && !out.unknown) api.releaseCheckNum(res.bankId, res.num); // (ldg) the number was never consumed: give it back return out; }, // ── Write check ──────────────────────────────────────────────── // (ldg, lead decision 15 / owner-reviewer spec 4.2 + 4.4) The SERVER writes the check in ONE transaction — the number, the // check record (id '-'), its CK- entry, the invoice it pays, the bank-rec row and the audit rows — through // POST /api/checks/post with an Idempotency-Key. This station never assembles the pieces any more: it sends the draft (and // the number it reserved), applies the records the server answers with, and only then offers printing. A retry after a // dropped connection uses the same key (GET /api/checks/op/ says whether it was committed), so a check is never posted // twice. Checks are found by (bankId, num) whatever their id form (findCheck); the print data is the snapshot the server stored. checkIdOf(c) { return c ? (c.id != null && c.id !== "" ? String(c.id) : String(c.num)) : ""; }, findCheck(ref, bankId) { const list = state.checks || []; const r = ref == null ? "" : String(ref).trim(); if (!r) return null; const byId = list.find(c => c && c.id != null && String(c.id) === r); if (byId) return byId; const m = /^([A-Za-z0-9_-]{1,40})-(\d{1,10})$/.exec(r); const num = m ? m[2] : r, bank = bankId || (m ? m[1] : null); const cfg = _checkCfg(); const hits = list.filter(c => c && String(c.num) === num && (!bank || (c.bankId || cfg.defaultId) === bank)); if (hits.length <= 1) return hits[0] || null; return hits.find(c => (c.bankId || cfg.defaultId) === cfg.defaultId) || hits[0]; }, // Put the records a command answered with into state as the server's copy (nothing left dirty, nothing pushed back). _applyServerRecords(records) { for (const x of records || []) { if (!x || !SERVER_COLLECTIONS[x.collection]) continue; const def = SERVER_COLLECTIONS[x.collection]; const id = def.singleton ? "__singleton__" : String(x.id); setStateRecord(x.collection, id, x.record); lastSent.set(recKey(x.collection, id), JSON.stringify(x.record)); if (x.rev != null) lastRev.set(recKey(x.collection, id), Number(x.rev)); } }, _newIdemKey(prefix) { return (prefix || "ck") + "-" + ((currentUser && currentUser.user) || "u") + "-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 10); }, // Send a command. When the connection drops AFTER sending, ask the server what became of the key before giving up // (spec 4.1.7: not submitted → send again with the same key; pending → wait; committed → use the answer; failed → the reason). // RC25 (imp): plain reads / writes for the import screens (the batch imports, the reconciliation picture) — the same token, // session and protocol headers as every other call; the Idempotency-Key commands keep using _command below. _apiGet(path) { return apiGet(path); }, _apiPost(path, body, headers) { return apiPostH(path, body, headers || {}); }, async _command(path, body, key, opPath) { const headers = { "Idempotency-Key": key }; const opBase = opPath || "/checks/op/"; // RC20 (fops): the RO / parts commands answer on /ops/op/ try { return { ok: true, res: await apiPostH(path, body, headers) }; } catch (e) { // the server answered: a refusal. (RC20 fops) A gateway error (502 / 504, or a 503 that is not the API's own JSON answer) means the // proxy lost the connection — the command may have COMMITTED: ask the server what became of the key, never guess. const gateway = e && (e.status === 502 || e.status === 504 || (e.status === 503 && !(e.body && e.body.code))); if (e && e.status && !gateway) return { ok: false, err: e }; for (let i = 0; i < 3; i++) { await new Promise(r => setTimeout(r, 700 * (i + 1))); let op = null; try { op = await apiGet(opBase + encodeURIComponent(key)); } catch (e2) { continue; } if (op && op.status === "committed") return { ok: true, res: op.result, recovered: true }; if (op && op.status === "failed") return { ok: false, err: Object.assign(new Error((op.result && op.result.error) || "refused"), { status: op.httpStatus, body: op.result }) }; if (op && op.status === "not_submitted") { try { return { ok: true, res: await apiPostH(path, body, headers), retried: true }; } catch (e3) { if (e3 && e3.status) return { ok: false, err: e3 }; } } } return { ok: false, err: e, unknown: true }; } }, // rec.num = a number this station reserved (reserveCheckNum / postCheck), or none: the server takes the next free one. async writeCheck(rec) { rec = rec || {}; const pre = api.checkDraftProblem(rec); if (pre) return { ok: false, msg: pre }; if (!AUTH_TOKEN || __offlineByChoice) return { ok: false, msg: api.CHECK_OFFLINE_MSG }; const amount = +(+rec.amount || 0).toFixed(2); const cfg = _checkCfg(); const bank = cfg.accounts.find(x => x.id === (rec.bankId || cfg.defaultId)) || cfg.accounts[0]; const num = rec.num != null && rec.num !== "" ? String(rec.num) : ""; if (num && !/^\d{1,10}$/.test(num)) return { ok: false, msg: "Check number must be digits only." }; const body = { bankId: bank.id, payee: String(rec.payee).trim(), amount, acct: String(rec.acct || "300").trim(), memo: rec.memo || "", inv: rec.inv || "", vendorId: rec.vendorId || "", payeeAddr: rec.payeeAddr != null ? String(rec.payeeAddr).slice(0, 200) : undefined, date: rec.date || undefined, reservedNum: num || undefined, cid: rec.cid ? String(rec.cid) : undefined, partial: rec.partial === true ? true : undefined, secondPayment: rec.secondPayment === true ? true : undefined }; // (ldg round 2) the server checks the bill: vendor, open amount, not paid already if (Array.isArray(rec.lines) && rec.lines.length) body.lines = rec.lines; const key = rec.idemKey || api._newIdemKey("ck"); const r = await api._command("/checks/post", body, key); 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 writing this check again, look for it in the check register — the office can look up key " + key + "." }; return { ok: false, key, code: b && b.code, msg: (b && b.error) || api._checkApiMsg(e) }; } const res = r.res; api._applyServerRecords(res.records); const check = (res.check && res.check.record) || api.findCheck(res.id); // (ldg round 2) a bill the server does not hold (entered on this station before bills carried an id): the server said so in // warnings[] and settled nothing — it is marked paid here, from the committed check, so it cannot be paid twice from this screen if (body.inv && !(res.records || []).some(x => x && x.collection === "payables")) { const bill = (state.payables || []).find(p => p && String(p.inv) === String(body.inv) && (p.status || "Open") === "Open"); if (bill) { bill.status = "Paid"; bill.paidDate = check.date; bill.checkNum = String(res.num); bill.checkId = res.id; bill.checkBankId = res.bankId; } } const warn = Array.isArray(res.warnings) && res.warnings.length ? " ⚠ " + res.warnings.join(" ") : ""; if ((parseInt(res.num, 10) || 0) >= (bank.nextNum || 0)) { bank.nextNum = (parseInt(res.num, 10) || 0) + 1; state.checkConfig = _checkCfgStore(cfg); } logAudit("Check Written", `#${res.num} ${check.payee} $${amount.toFixed(2)} → DR ${check.acct} / CR ${check.bankAcct} (${bank.label || ""}) · ${res.id}`); persist(); notify(); return { ok: true, num: String(res.num), id: res.id, check, key, recovered: !!r.recovered, warnings: res.warnings || [], msg: `Check #${res.num} written to ${check.payee} — $${amount.toLocaleString("en-US", { minimumFractionDigits: 2 })} (DR ${check.acct} / CR ${check.bankAcct}).` + warn }; }, markChecksPrinted(refs) { const hit = new Set(); (refs || []).forEach(r => { const c = api.findCheck(r && typeof r === "object" ? api.checkIdOf(r) : r); if (c) hit.add(c); }); hit.forEach(c => { c.printCount = (c.printCount || 0) + 1; c.printedAt = _iso(new Date()); }); logAudit("Checks Printed", Array.from(hit).map(c => "#" + c.num).join(", ")); persist(); notify(); }, // Void = the mirror command (POST /api/checks/void): the CKV- entry, status Void with the reason, the invoice reopened, the // bank-rec row removed, the number kept as void — one transaction on the server, applied here from its answer. async voidCheck(ref, reason, opts) { opts = opts || {}; const c = api.findCheck(ref); if (!c) return { ok: false, msg: "Check not found." }; if (c.status === "Void") return { ok: false, msg: "Check already void." }; if (c.status === "Cleared") return { ok: false, msg: "Check already cleared the bank — it can't be voided here." }; if (!String(reason || "").trim()) return { ok: false, msg: "A reason is required to void a check." }; if (!AUTH_TOKEN || __offlineByChoice) return { ok: false, msg: api.CHECK_OFFLINE_MSG }; const bankAcct = c.bankAcct || ACCT.cashBank; const body = c.id ? { id: String(c.id), reason: String(reason).trim() } : { bankId: c.bankId || _checkCfg().defaultId, num: String(c.num), reason: String(reason).trim() }; if (opts.reissue === true) body.reissue = true; // RC28 (fx): Void & reissue — the replacement check in the same step const key = opts.idemKey || api._newIdemKey("ckv"); const r = await api._command("/checks/void", body, key); 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. Look at the register before voiding again (key " + key + ")." }; return { ok: false, key, code: b && b.code, msg: (b && b.error) || api._checkApiMsg(e) }; } api._applyServerRecords(r.res.records); logAudit(opts.reissue ? "Check Voided & Reissued" : "Check Voided", `#${c.num} ${c.payee} $${(+c.amount || 0).toFixed(2)}${reason ? " · " + reason : ""} · ${r.res.id}${r.res.reissued ? " → #" + r.res.reissued.num : ""}`); persist(); notify(); // RC28 (fx, finding 2): the server routed the void to what the check paid (an RO refund, a deal refund, a bill) — its words say what changed return { ok: true, id: r.res.id, owner: r.res.owner, reissued: r.res.reissued || null, msg: r.res.message || `Check #${c.num} voided — ${bankAcct} restored${c.inv ? ", invoice " + c.inv + " reopened" : ""}.` }; }, clearCheck(ref) { const c = api.findCheck(ref); if (!c) return { ok: false, msg: "Check not found." }; const num = c.num; c.status = "Cleared"; c.clearedDate = _iso(new Date()); if (state.bankRec && state.bankRec.outstandingChecks) state.bankRec.outstandingChecks = state.bankRec.outstandingChecks.filter(x => !(String(x.num) === String(num) && (!x.bankId || !c.bankId || x.bankId === c.bankId))); persist(); notify(); return { ok: true, msg: `Check #${num} marked cleared.` }; }, // ════════════════════════════════════════════════════════════════ // BODY SHOP — collision repair orders. Mirrors service RO posting: // labor (body/paint/frame/mechanical) + parts (reserved from stock) // + sublet, billed to insurance or customer, posted to the GL on close. // ════════════════════════════════════════════════════════════════ BODY_RATES: { body: 58, paint: 62, frame: 75, mechanical: 145 }, // RC32 (M5): a body labor line's starting rate = the store's (today's BODY_RATES when not set) bodyRate(type) { let r = null; try { const m = api.svcSettings ? api.svcSettings().money : null; r = m && m.bodyRates ? +m.bodyRates[type] : null; } catch (e) { r = null; } return r > 0 ? r : api.BODY_RATES[type]; }, createBodyRO(detail = {}) { state.bodyShopROs = state.bodyShopROs || []; const id = "BR-" + String(44600 + state.bodyShopROs.length).slice(-5); state.bodyShopROs.unshift({ id, customer: detail.customer || "", custNum: detail.custNum || "", vin: detail.vin || "", vehicle: detail.vehicle || "", payType: detail.payType || "Insurance", insurer: detail.insurer || "", claimNo: detail.claimNo || "", deductible: +detail.deductible || 0, estimator: detail.estimator || "Body Shop", status: "Estimate", lines: [], partsLines: [], sublets: [], supplements: [], photos: [], laborSale: 0, laborCost: 0, partsSale: 0, partsCost: 0, subletSale: 0, subletCost: 0, tax: 0, total: 0, created: _iso(new Date()), }); persist(); notify(); return { ok: true, id, msg: `Body shop RO ${id} created.` }; }, _bodyRO(id) { return (state.bodyShopROs || []).find(r => r.id === id); }, updateBodyRO(id, patch) { const r = api._bodyRO(id); if (!r) return { ok: false, msg: "RO not found." }; Object.assign(r, patch); api.recalcBodyRO(id); persist(); notify(); return { ok: true, msg: "Updated." }; }, addBodyLine(id, line) { const r = api._bodyRO(id); if (!r) return { ok: false, msg: "RO not found." }; const type = ["body", "paint", "frame", "mechanical"].includes(line.type) ? line.type : "body"; const hours = +line.hours || 0; const rate = +line.rate || api.bodyRate(type); // RC32 (M5) r.lines.push({ id: "L" + Date.now().toString(36).slice(-5), type, desc: line.desc || "", hours, rate, sale: +(hours * rate).toFixed(2) }); api.recalcBodyRO(id); persist(); notify(); return { ok: true, msg: "Labor line added." }; }, updateBodyLine(id, lineId, patch) { const r = api._bodyRO(id); if (!r) return { ok: false }; const l = r.lines.find(x => x.id === lineId); if (!l) return { ok: false }; Object.assign(l, patch); const rate = +l.rate || api.bodyRate(l.type); l.sale = +((+l.hours || 0) * rate).toFixed(2); // RC32 (M5) api.recalcBodyRO(id); persist(); notify(); return { ok: true }; }, removeBodyLine(id, lineId) { const r = api._bodyRO(id); if (!r) return { ok: false }; r.lines = r.lines.filter(x => x.id !== lineId); api.recalcBodyRO(id); persist(); notify(); return { ok: true }; }, reserveBodyPart(id, partNo, qty) { const r = api._bodyRO(id); if (!r) return { ok: false, msg: "RO not found." }; const p = state.parts.find(x => x.part === partNo); if (!p) return { ok: false, msg: "Part not found in master." }; const q = +qty || 1; const avail = p.onHand - p.committed; p.committed += q; r.partsLines.push({ id: "P" + Date.now().toString(36).slice(-5), part: p.part, desc: p.desc, qty: q, unit: p.list, cost: p.cost, sale: +(p.list * q).toFixed(2), reserved: true, backorder: q > avail }); api.recalcBodyRO(id); persist(); notify(); return { ok: true, msg: `Reserved ${q}× ${partNo}${q > avail ? " (backorder — exceeds on-hand)" : ""}.` }; }, removeBodyPart(id, lineId) { const r = api._bodyRO(id); if (!r) return { ok: false }; const line = (r.partsLines || []).find(l => l.id === lineId); if (!line) return { ok: false }; const p = state.parts.find(x => x.part === line.part); if (p) p.committed = Math.max(0, p.committed - line.qty); r.partsLines = r.partsLines.filter(l => l.id !== lineId); api.recalcBodyRO(id); persist(); notify(); return { ok: true }; }, addSublet(id, sub) { const r = api._bodyRO(id); if (!r) return { ok: false, msg: "RO not found." }; const cost = +sub.cost || 0; const sale = +sub.sale || +(cost * 1.25).toFixed(2); r.sublets.push({ id: "S" + Date.now().toString(36).slice(-5), vendor: sub.vendor || "", desc: sub.desc || "", cost, sale }); api.recalcBodyRO(id); persist(); notify(); return { ok: true, msg: `Sublet to ${sub.vendor || "vendor"} added.` }; }, removeSublet(id, subId) { const r = api._bodyRO(id); if (!r) return { ok: false }; r.sublets = r.sublets.filter(s => s.id !== subId); api.recalcBodyRO(id); persist(); notify(); return { ok: true }; }, addSupplement(id, sup) { const r = api._bodyRO(id); if (!r) return { ok: false, msg: "RO not found." }; r.supplements.push({ id: "X" + Date.now().toString(36).slice(-5), desc: sup.desc || "", amount: +sup.amount || 0, status: "Pending", date: _iso(new Date()) }); persist(); notify(); return { ok: true, msg: "Supplement submitted for approval." }; }, setSupplementStatus(id, supId, status) { const r = api._bodyRO(id); if (!r) return { ok: false }; const s = (r.supplements || []).find(x => x.id === supId); if (!s) return { ok: false }; s.status = status; s.decidedDate = _iso(new Date()); logAudit("Body Supplement " + status, `${id} — ${s.desc} $${(+s.amount||0).toFixed(2)}`); api.recalcBodyRO(id); persist(); notify(); return { ok: true, msg: `Supplement ${status.toLowerCase()}.` }; }, recalcBodyRO(id) { const r = api._bodyRO(id); if (!r) return; r.laborSale = +(r.lines || []).reduce((s, l) => s + (+l.sale || 0), 0).toFixed(2); r.laborCost = +(r.laborSale * 0.4).toFixed(2); r.partsSale = +(r.partsLines || []).reduce((s, l) => s + (+l.sale || 0), 0).toFixed(2); r.partsCost = +(r.partsLines || []).reduce((s, l) => s + (+l.cost || 0) * (+l.qty || 0), 0).toFixed(2); r.subletSale = +(r.sublets || []).reduce((s, l) => s + (+l.sale || 0), 0).toFixed(2); r.subletCost = +(r.sublets || []).reduce((s, l) => s + (+l.cost || 0), 0).toFixed(2); const approvedSupp = +(r.supplements || []).filter(s => s.status === "Approved").reduce((s, x) => s + (+x.amount || 0), 0).toFixed(2); r.supplementTotal = approvedSupp; r.tax = +(r.partsSale * (window.jbTaxRate ? window.jbTaxRate("bodyshop") || window.jbTaxRate("service") : 0.07225)).toFixed(2); r.total = +(r.laborSale + r.partsSale + r.subletSale + approvedSupp + r.tax).toFixed(2); }, // RC24 (svcm): the PREVIEW of a body-shop close — the same calculation the server commits (window.SvcmCalc = src/lib/svcm-calc.js): // labor, parts (at the job's stored cost), sublet, approved supplements, tax on parts at the shared service rate, the insurer / // customer split with the deductible. Once closed, the server's committed invoice (foInvoice) is what shows and prints. bodyMoney(jobOrId) { const r = jobOrId && typeof jobOrId === "object" ? jobOrId : api._bodyRO(jobOrId); if (!r) return null; const SC = window.SvcmCalc; if (r.status === "Closed" && r.foInvoice) { const v = r.foInvoice; return { committed: true, invoice: v, total: v.totalCents / 100, labor: v.laborCents / 100, parts: v.partsCents / 100, sublet: v.subletCents / 100, supplements: v.supplementCents / 100, tax: v.taxCents / 100, deductible: v.deductibleCents / 100, insurer: v.insurerCents / 100, customer: v.customerCents / 100, paid: v.paidCents / 100, customerOwed: v.customerOwedCents / 100, payType: v.payType }; } if (r.status === "Closed" || !SC) { return { committed: r.status === "Closed", stored: true, total: +r.total || 0, labor: +r.laborSale || 0, parts: +r.partsSale || 0, sublet: +r.subletSale || 0, supplements: +r.supplementTotal || 0, tax: +r.tax || 0, deductible: r.payType === "Insurance" ? Math.min(+r.deductible || 0, +r.total || 0) : 0, insurer: r.payType === "Insurance" ? Math.max(0, (+r.total || 0) - Math.min(+r.deductible || 0, +r.total || 0)) : 0, customer: r.payType === "Insurance" ? Math.min(+r.deductible || 0, +r.total || 0) : (+r.total || 0), paid: +r.paidAtClose || 0, customerOwed: r.customerOwed != null ? +r.customerOwed : null, payType: r.payType }; } const t = SC.bodyTotals(r, api.fopsTaxConfig() || {}); return { committed: false, totals: t, problems: t.problems, total: t.totalCents / 100, labor: t.laborCents / 100, parts: t.partsCents / 100, sublet: t.subletCents / 100, supplements: t.supplementCents / 100, tax: t.taxCents / 100, deductible: t.deductibleCents / 100, insurer: t.insurerCents / 100, customer: t.customerCents / 100, paid: 0, customerOwed: t.customerCents / 100, payType: t.payType }; }, // RC24 (svcm): CLOSE = one server command (POST /api/fixedops/bodyshop/close). The server checks the role, re-reads the job, refuses // pending supplements, computes the money (SvcmCalc), relieves the stock, creates the sublet vendors' payables, books the insurer // receivable (262) / the customer's share (payments taken now, the rest on the customer's A/R 220) and posts BS- — all or nothing. // Nothing changes on this station until the server says "committed"; then the committed job / invoice replace the local copy. async closeBodyRO(id, opts) { opts = opts || {}; const r = api._bodyRO(id); if (!r) return { ok: false, msg: "RO not found." }; if (r.status === "Closed") return { ok: false, msg: `${id} already closed.` }; if (!api.fopsCanCloseRO()) return { ok: false, denied: true, msg: `Your role (${currentRole() || "no role"}) can't close body-shop repair orders — a service advisor, the service manager or the office can.` }; if (!AUTH_TOKEN || __offlineByChoice) return { ok: false, msg: "Closing a body-shop RO posts it to the books, which needs the server. Connect to the server, then close it." }; api.recalcBodyRO(id); const saved = await api._fopsSaveFirst("bodyShopROs", id); if (!saved.ok) return { ok: false, msg: saved.msg }; const key = opts.idemKey || api._newIdemKey("bs"); const body = { jobId: id, baseRev: saved.rev }; if (Array.isArray(opts.payments) && opts.payments.length) body.payments = opts.payments; const rr = await api._command("/fixedops/bodyshop/close", body, key, "/ops/op/"); if (!rr.ok) { const e = rr.err; const b = (e && e.body) || {}; if (rr.unknown) return { ok: false, unknown: true, key, msg: `The server did not answer. Before closing ${id} again, look at it on the Body Shop screen — if it shows Closed it was posted. (The office can look up ${key}.)` }; if (b.record && b.rev != null) api._applyServerRecords([{ collection: "bodyShopROs", id, rev: b.rev, record: b.record }]); notify(); return { ok: false, key, code: b.code, msg: b.error || (e && e.message) || "The server refused to close this body-shop RO.", problems: b.problems }; } const res = rr.res; api._applyServerRecords(res.records); const t = res.totals || {}; logAudit("Body Shop RO Closed", `${id} → ${res.jeId} · total $${((t.totalCents || 0) / 100).toFixed(2)}${t.insurerCents ? " · insurer $" + (t.insurerCents / 100).toFixed(2) : ""}${t.customerOwedCents ? " · customer owes $" + (t.customerOwedCents / 100).toFixed(2) : ""}${rr.recovered ? " (answer recovered after a dropped connection)" : ""}`); persist(); notify(); const warn = Array.isArray(res.warnings) && res.warnings.length ? " ⚠ " + res.warnings.join(" ") : ""; return { ok: true, key, jeId: res.jeId, invoice: res.invoice, totals: t, payables: res.payables || [], warnings: res.warnings || [], msg: (res.message || `${id} closed and posted (${res.jeId}).`) + warn }; }, importBodyEstimate(id, rows) { const r = api._bodyRO(id); if (!r) return { ok: false, msg: "RO not found." }; let n = 0; (rows || []).forEach(row => { const type = ["body", "paint", "frame", "mechanical"].includes(String(row.type).toLowerCase()) ? String(row.type).toLowerCase() : "body"; if (row.part) { const p = state.parts.find(x => x.part === String(row.part).toUpperCase()); if (p) { api.reserveBodyPart(id, p.part, +row.qty || 1); n++; return; } } const hours = +row.hours || 0; const rate = +row.rate || api.bodyRate(type); // RC32 (M5) r.lines.push({ id: "L" + Date.now().toString(36).slice(-5) + n, type, desc: row.desc || row.operation || "", hours, rate, sale: +(hours * rate).toFixed(2) }); n++; }); api.recalcBodyRO(id); persist(); notify(); return { ok: true, msg: `Imported ${n} estimate line(s) into ${id}.` }; }, clearBankRec() { if (!state.bankRec) return { ok: false, msg: "No bank rec." }; const br = state.bankRec; const oc = (br.outstandingChecks || []).reduce((s, c) => s + c.amount, 0); const dit = (br.depositsInTransit || []).reduce((s, d) => s + d.amount, 0); const adjBank = br.bankBalance - oc + dit; const diff = +(adjBank - br.bookBalance).toFixed(2); if (Math.abs(diff) > 0.01) { return { ok: false, msg: `Out of balance by $${Math.abs(diff).toFixed(2)}. Adjusted bank ${adjBank.toLocaleString()} vs book ${br.bookBalance.toLocaleString()}.` }; } br.cleared = true; br.clearedDate = _iso(new Date()); persist(); notify(); return { ok: true, msg: `Bank reconciled — adjusted balance $${adjBank.toLocaleString()} ties to book.` }; }, // ── Accounting period / post-ahead API ───────────────────────── periodStatus() { const ap = _acctPeriod(); const jes = state.journalEntries || []; const ahead = jes.filter(j => j.postAhead); const nice = (p) => { const [y, m] = p.split("-"); return new Date(+y, +m - 1, 1).toLocaleDateString("en-US", { month: "long", year: "numeric" }); }; return { open: ap.open, openLabel: nice(ap.open), closed: ap.closed || [], nowPeriod: _periodOf(), inNewMonth: _periodOf() > ap.open, aheadCount: ahead.length, aheadTotal: +ahead.reduce((s, j) => s + (j.lines || []).reduce((x, l) => x + (+l.dr || 0), 0), 0).toFixed(2) }; }, closeMonth() { if (!api.can("period")) return { ok: false, denied: true, msg: "Your role can't close the month — ask the office manager or an admin." }; const ap = _acctPeriod(); const closing = ap.open; // advance open period by one month const [y, m] = closing.split("-").map(Number); const next = (m === 12 ? (y + 1) + "-01" : y + "-" + String(m + 1).padStart(2, "0")); ap.closed = ap.closed || []; if (!ap.closed.includes(closing)) ap.closed.push(closing); ap.open = next; // Release post-ahead entries that belong to the newly opened period. let released = 0; (state.journalEntries || []).forEach(j => { if (j.postAhead && j.period <= next) { delete j.postAhead; released++; } }); const warnings = api.monthEndChecklist().filter(c => !(c.ok ?? c.done)).length; logAudit("Month Closed", `${closing} closed → ${next} open · ${released} post-ahead entr${released === 1 ? "y" : "ies"} released${warnings ? " · " + warnings + " checklist exception(s) at close" : ""}`); persist(); notify(); return { ok: true, released, warnings, msg: `${closing} closed — ${next} is now the open period. ${released} post-ahead entr${released === 1 ? "y" : "ies"} released${warnings ? ` (${warnings} checklist exception${warnings === 1 ? "" : "s"} noted)` : ""}.` }; }, // ════════════════════════════════════════════════════════════════ // GENERAL LEDGER — editing with an audit trail. Rules: // • any entry can be REVERSED (mirror entry, original marked); // • a MANUAL entry (Doc Entry / Accounting / Manual) in the OPEN or a // later period is edited in place; // • a SYSTEM entry (Sales, F&I, Payroll, Inventory…) or one in a CLOSED // period is CORRECTED = reverse + post the corrected copy, so the // original posting stays on the books and the trail is complete. // ════════════════════════════════════════════════════════════════ // "Opening balances" = the entry the Trial balance import posts (ref OB-yyyymmdd). It is treated like a // manual entry: a typo can be fixed in place while its month is open, or reversed (RV-OB-…) at any time. OPENING_SRC: "Opening balances", MANUAL_JE_SOURCES: ["DocEntry", "Accounting", "Manual", "Adjustment", "Opening balances"], jeBalance(lines) { const dr = (lines || []).reduce((s2, l) => s2 + (+l.dr || 0), 0), cr = (lines || []).reduce((s2, l) => s2 + (+l.cr || 0), 0); return { dr: +dr.toFixed(2), cr: +cr.toFixed(2), diff: +(dr - cr).toFixed(2), ok: Math.abs(dr - cr) < 0.005 && dr > 0 }; }, journalEntry(id) { return (state.journalEntries || []).find(j => j && String(j.id) === String(id)) || null; }, periodIsClosed(period) { const ap = _acctPeriod(); return (ap.closed || []).includes(period); }, // New manual journal entry (validated, balanced) → posts to the GL. postJournalEntry(entry) { if (!api.can("glEdit")) return { ok: false, denied: true, msg: "Your role can't post journal entries — ask the office manager or an admin." }; const e = entry || {}; // control # (stock / deal / customer) and its description ride along on the line when given const lines = (e.lines || []).filter(l => l && l.acct && ((+l.dr || 0) || (+l.cr || 0))).map(l => ({ acct: String(l.acct), dr: +(+l.dr || 0).toFixed(2), cr: +(+l.cr || 0).toFixed(2), desc: l.desc || "", ...(l.control ? { control: String(l.control) } : {}), ...(l.controlName ? { controlName: String(l.controlName) } : {}) })); const b = api.jeBalance(lines); if (!b.ok) return { ok: false, msg: b.dr ? `Out of balance by $${Math.abs(b.diff).toFixed(2)} — debits must equal credits.` : "Enter at least one debit and one credit." }; const date = e.date ? _fmtDate(e.date) : _fmtDate(new Date()); const per = _periodOf(e.date || new Date()); if (api.periodIsClosed(per)) return { ok: false, msg: `${per} is closed — date the entry in the open period, or reopen ${per} first.` }; const id = e.id || ("JE-" + String(Date.now()).slice(-6)); if (api.journalEntry(id)) return { ok: false, msg: `Entry ${id} already exists.` }; // e.extra: reference fields an importer stamps on the entry (importId, asOf, mtdRef …) — never overrides the core fields postGL({ ...(e.extra && typeof e.extra === "object" ? e.extra : {}), id, date, src: e.src || "Manual", desc: e.desc || "Manual journal entry", jrn: +e.jrn || 80, ref: e.ref || "", lines, by: currentUser.user }); persist(); notify(); return { ok: true, id, msg: `${id} posted — $${b.dr.toLocaleString()} in balance.` }; }, // Reverse any posted entry. Posts RV- with debits/credits swapped. reverseJournalEntry(id, reason) { if (!api.can("glEdit")) return { ok: false, denied: true, msg: "Your role can't reverse journal entries — ask the office manager or an admin." }; const je = api.journalEntry(id); if (!je) return { ok: false, msg: "Entry not found." }; if (je.reversedBy) return { ok: false, msg: `${id} was already reversed by ${je.reversedBy}.` }; if (je.reverses) return { ok: false, msg: `${id} is itself a reversal — post a new entry instead.` }; if (je.checkId || /^CKV?-/.test(String(je.id))) return { ok: false, msg: `${id} belongs to check #${je.ref || ""} — void the check in Check Writing instead (that reverses the entry and keeps the register in step).` }; // (ldg) const ap = _acctPeriod(); const date = api.periodIsClosed(_periodOf(je.date)) ? _fmtDate(new Date()) : je.date; // closed month → reverse in today's period let rid = "RV-" + id; let n = 2; while (api.journalEntry(rid)) rid = "RV" + (n++) + "-" + id; postGL({ id: rid, date, src: "Adjustment", desc: `REVERSAL of ${id} — ${reason || "no reason given"}`, jrn: je.jrn || 80, reverses: id, ref: je.ref || "", lines: (je.lines || []).map(l => ({ acct: l.acct, dr: +l.cr || 0, cr: +l.dr || 0, desc: "Reverse: " + (l.desc || "") })), by: currentUser.user }); je.reversedBy = rid; je.reversedAt = _iso(new Date()); je.reverseReason = reason || ""; logAudit("JE Reversed", `${id} → ${rid}${reason ? " · " + reason : ""}`); persist(); notify(); return { ok: true, id: rid, msg: `${id} reversed by ${rid}${ap && date !== je.date ? " (dated today — original month is closed)" : ""}.` }; }, // Edit an entry. Manual + open period → in place. Otherwise → reverse + repost. editJournalEntry(id, patch, reason) { if (!api.can("glEdit")) return { ok: false, denied: true, msg: "Your role can't edit journal entries — ask the office manager or an admin." }; const je = api.journalEntry(id); if (!je) return { ok: false, msg: "Entry not found." }; if (je.reversedBy) return { ok: false, msg: `${id} was reversed — edit its replacement instead.` }; const p = patch || {}; const lines = (p.lines || je.lines || []).filter(l => l && l.acct && ((+l.dr || 0) || (+l.cr || 0))).map(l => ({ acct: String(l.acct), dr: +(+l.dr || 0).toFixed(2), cr: +(+l.cr || 0).toFixed(2), desc: l.desc || "" })); const b = api.jeBalance(lines); if (!b.ok) return { ok: false, msg: `Out of balance by $${Math.abs(b.diff).toFixed(2)} — debits must equal credits.` }; const newDate = p.date ? _fmtDate(p.date) : je.date; const oldPer = _periodOf(je.date), newPer = _periodOf(newDate); if (api.periodIsClosed(newPer)) return { ok: false, msg: `${newPer} is closed — pick a date in an open period.` }; // (ledger lane) A posted entry is never changed in place — the server refuses it ("Posted entries cannot be changed. // Post a reversing entry instead."). Every correction, manual entries included, is reverse + corrected copy. const manual = false; if (manual && !api.periodIsClosed(oldPer)) { const before = JSON.stringify({ date: je.date, desc: je.desc, lines: je.lines }); je.date = newDate; je.period = newPer; je.desc = p.desc != null ? p.desc : je.desc; je.ref = p.ref != null ? p.ref : je.ref; je.lines = lines; je.edits = [...(je.edits || []), { at: _iso(new Date()), by: currentUser.user, reason: reason || "", before }]; logAudit("JE Edited", `${id}${reason ? " · " + reason : ""} · now $${b.dr.toLocaleString()}`); persist(); notify(); return { ok: true, id, mode: "edited", msg: `${id} updated in place (manual entry, open period).` }; } // System / closed-period entry → reverse, then post the corrected copy. const rv = api.reverseJournalEntry(id, "Correction" + (reason ? ": " + reason : "")); if (!rv.ok) return rv; let cid = id + "-C"; let n = 2; while (api.journalEntry(cid)) cid = id + "-C" + (n++); postGL({ id: cid, date: api.periodIsClosed(newPer) ? _fmtDate(new Date()) : newDate, src: je.src, desc: (p.desc != null ? p.desc : je.desc) + " (corrected)", jrn: je.jrn || 80, ref: je.ref || "", corrects: id, lines, by: currentUser.user }); logAudit("JE Corrected", `${id} → reversed ${rv.id}, corrected ${cid}${reason ? " · " + reason : ""}`); persist(); notify(); return { ok: true, id: cid, mode: "corrected", msg: `${id} was a posted ${je.src} entry — reversed (${rv.id}) and re-posted as ${cid}.` }; }, // Accounting period control (admin). One period for every PC (server singleton). setAcctPeriod(openPeriod) { if (!api.can("period")) return { ok: false, denied: true, msg: "Your role can't change the accounting period — ask the office manager or an admin." }; if (!/^\d{4}-\d{2}$/.test(String(openPeriod || ""))) return { ok: false, msg: "Period must look like 2026-09." }; const ap = _acctPeriod(); const prev = ap.open; ap.open = openPeriod; ap.closed = (ap.closed || []).filter(p => p < openPeriod); // anything at/after the open period is open again logAudit("Period Changed", `${prev} → ${openPeriod}`); persist(); notify(); return { ok: true, msg: `Open period set to ${openPeriod}.` }; }, reopenPeriod(period) { if (!api.can("period")) return { ok: false, denied: true, msg: "Your role can't reopen a closed month — ask the office manager or an admin." }; const ap = _acctPeriod(); if (!(ap.closed || []).includes(period)) return { ok: false, msg: `${period} is not closed.` }; ap.closed = ap.closed.filter(p => p !== period); logAudit("Period Reopened", period); persist(); notify(); return { ok: true, msg: `${period} reopened — entries can be posted or edited there again.` }; }, periodRange(period) { const [y, m] = String(period || _acctPeriod().open).split("-").map(Number); const start = new Date(y, m - 1, 1), end = new Date(y, m, 0); return { start, end, startLabel: _fmtDate(start), endLabel: _fmtDate(end) }; }, // Where did this entry come from? → { kind: "deal"|"ro"|"check"|…, id } jeSource(je) { const j = typeof je === "string" ? api.journalEntry(je) : je; if (!j) return null; if (j.dealId) return { kind: "deal", id: j.dealId }; const txt = [j.desc, j.ref].join(" "); const m = txt.match(/\b(DR-\d{5,})\b/); if (m) return { kind: "deal", id: m[1] }; const suf = String(j.id).match(/^(?:LS|VS|CS|FI|CM|CR|ADJ)-(\d{5})\b/); if (suf) { const d = (state.deals || []).find(x => String(x.id).endsWith(suf[1])); if (d) return { kind: "deal", id: d.id }; } const ro = txt.match(/\b(RO-?\d+)\b/i); if (ro) return { kind: "ro", id: ro[1] }; return null; }, // ════════════════════════════════════════════════════════════════ // ADJUST A POSTED DEAL (funding-desk "Edit Backend"). Changes to price, // fees, tax, products, payoff or extra charges post ONE adjusting entry // (ADJ--n) for the difference; the balancing side goes to // Contracts in Transit (financed, not yet funded — amount financed moves // with it), Cash (cash deal) or Customer A/R (already funded). // ════════════════════════════════════════════════════════════════ // RC33 (mpx): a SERVER command now — MoneyCore.jsx.txt (loaded after this file) replaces it; this stub only answers when that file is missing adjustPostedDeal(dealId, patch, reason) { return moneyMissing("Correcting a posted deal"); }, // Month-end readiness — live checklist driven by exceptions monthEndChecklist() { const x = api.exceptions(); return [ { key: "deals", label: "All delivered deals posted to GL", done: x.notPosted.length === 0, detail: x.notPosted.length ? `${x.notPosted.length} unposted` : "clear" }, { key: "ros", label: "All repair orders closed", done: x.openROs.length === 0, detail: x.openROs.length ? `${x.openROs.length} open` : "clear" }, { key: "cit", label: "Contracts in transit funded", done: x.soldNotFunded.length === 0, detail: x.soldNotFunded.length ? `${x.soldNotFunded.length} unfunded` : "clear" }, { key: "floor", label: "Floorplan payoffs current", done: x.floorplanDue.length === 0, detail: x.floorplanDue.length ? `${x.floorplanDue.length} due` : "clear" }, { key: "warranty", label: "Warranty claims submitted", done: x.unsubmittedWarranty.length === 0, detail: x.unsubmittedWarranty.length ? `${x.unsubmittedWarranty.length} unsubmitted` : "clear" }, { key: "factory", label: "Factory receivables current (<30d)", done: x.agedFactory.length === 0, detail: x.agedFactory.length ? `${x.agedFactory.length} aged` : "clear" }, { key: "parts", label: "No negative parts on-hand", done: x.negativeParts.length === 0, detail: x.negativeParts.length ? `${x.negativeParts.length} negative` : "clear" }, { key: "bank", label: "Bank reconciliation cleared", done: !!(state.bankRec && state.bankRec.cleared), detail: (state.bankRec && state.bankRec.cleared) ? "reconciled" : "not reconciled" }, ]; }, // ════════════════════════════════════════════════════════════════ // EXCEPTIONS — the GM's morning report (spec §11) // ════════════════════════════════════════════════════════════════ exceptions() { // (blocker 4, verifier) never run the report on an unloaded state: a locked / not-yet-decrypted placeholder has no // collections, and the Dashboard renders first — an empty report instead of a React crash (blank screen) const s = (state && !state.__locked && Array.isArray(state.deals)) ? state : _blank(); const daysSince = (iso) => iso ? Math.floor((Date.now() - new Date(iso)) / 86400000) : 999; // H13 (sales features, extended to the Dashboard's money rows): a Sales login receives other // people's deals without amounts (_limited) — they are not this person's money to chase, so they // are left out here; the rest get number guards so a missing amount never shows "$NaN". const soldNotFunded = s.deals.filter(d => d.status === "Delivered Not Funded" && !d._limited) .map(d => (typeof d.amtFinanced === "number" && isFinite(d.amtFinanced) && d.lender) ? d : Object.assign({}, d, { amtFinanced: +d.amtFinanced || 0, lender: d.lender || "no lender on file" })); const soldNotDelivered = s.deals.filter(d => d.status === "Sold Not Delivered"); const notPosted = s.deals.filter(d => !d.postedToGL && d.status !== "Working" && d.salePrice > 0 && !d._limited); const openROs = s.repairOrders.filter(r => r.status === "Open"); const agedROs = openROs.filter(r => daysSince(r.opened) >= 7); const unpaidWarranty = s.warrantyReceivables.filter(w => !w.paid && w.status !== "Debited" && !w.voided); // RC33 (mpx): a charged-back / voided claim is not an open receivable const unsubmittedWarranty = s.warrantyReceivables.filter(w => !w.submitted); const agedWarranty = s.warrantyReceivables.filter(w => w.submitted && !w.paid && daysSince(w.submittedDate) >= 30); const openFactory = s.factoryReceivables.filter(f => !f.received); const agedFactory = openFactory.filter(f => f.age >= 30); const negativeParts = s.parts.filter(p => p.onHand < 0); const noBinParts = s.parts.filter(p => !p.bin); const obsoleteParts = s.parts.filter(p => p.age >= 365 || (p.lastSale && daysSince(p.lastSale) >= 365) || (!p.lastSale && p.age >= 180)); const soReceivedNotInstalled = s.parts.filter(p => p.specialOrder && p.specialOrder.received && !p.specialOrder.installed); const floorplanDue = s.floorplanPayoffsDue.filter(f => !f.paid); const unpaidCommissions = s.commissions.filter(c => !c.paid); const internalROsOpen = openROs.filter(r => r.payType === "Internal"); const agedInventory = s.inventory.filter(u => u.status === "In Stock" && u.age >= 90); return { soldNotFunded, soldNotDelivered, notPosted, openROs, agedROs, unpaidWarranty, unsubmittedWarranty, agedWarranty, openFactory, agedFactory, negativeParts, noBinParts, obsoleteParts, soReceivedNotInstalled, floorplanDue, unpaidCommissions, internalROsOpen, agedInventory, }; }, // ════════════════════════════════════════════════════════════════ // ACCOUNTS RECEIVABLE — open-item sub-ledger for acct 220 // Charges, payments, finance charges, deposits all post to the GL // through the same JEStore the Accounting screen reads. // ════════════════════════════════════════════════════════════════ // FIFO aging for one customer: applies credits to oldest charges first, // returns {balance, buckets, openItems}. Pure read — no mutation. arAge(acct) { const c = (state.arCustomers || []).find(x => x.acct === acct); if (!c) return { balance: 0, buckets: { "0-30": 0, "31-60": 0, "61-90": 0, "90+": 0 }, openItems: [] }; const daysSince = (iso) => iso ? Math.floor((Date.now() - new Date(iso)) / 86400000) : 0; const bucketOf = (d) => d <= 30 ? "0-30" : d <= 60 ? "31-60" : d <= 90 ? "61-90" : "90+"; const txns = [...(c.txns || [])].sort((a, b) => new Date(a.date) - new Date(b.date)); // charges become open items; payments/credits consume oldest first const open = []; let creditPool = 0; txns.forEach(t => { if (t.charge > 0) open.push({ ...t, remaining: t.charge }); if (t.credit > 0) creditPool += t.credit; }); for (const item of open) { if (creditPool <= 0) break; const applied = Math.min(item.remaining, creditPool); item.remaining = +(item.remaining - applied).toFixed(2); creditPool = +(creditPool - applied).toFixed(2); } const buckets = { "0-30": 0, "31-60": 0, "61-90": 0, "90+": 0 }; const openItems = open.filter(i => i.remaining > 0.005); openItems.forEach(i => { buckets[bucketOf(daysSince(i.date))] += i.remaining; }); const unappliedCredit = creditPool; // customer credit balance const balance = +(openItems.reduce((s, i) => s + i.remaining, 0) - unappliedCredit).toFixed(2); return { balance, buckets, openItems, unappliedCredit, daysSince, bucketOf }; }, // Whole-portfolio aging summary (trial balance / dashboard) arSummary() { const rows = (state.arCustomers || []).map(c => { const a = api.arAge(c.acct); const oldest = a.openItems.reduce((m, i) => Math.max(m, a.daysSince(i.date)), 0); return { acct: c.acct, name: c.name, type: c.type, balance: a.balance, buckets: a.buckets, oldest, current: a.buckets["0-30"], pastDue: a.buckets["31-60"] + a.buckets["61-90"] + a.buckets["90+"] }; }); const totals = { "0-30": 0, "31-60": 0, "61-90": 0, "90+": 0, balance: 0 }; rows.forEach(r => { totals["0-30"] += r.buckets["0-30"]; totals["31-60"] += r.buckets["31-60"]; totals["61-90"] += r.buckets["61-90"]; totals["90+"] += r.buckets["90+"]; totals.balance += r.balance; }); return { rows, totals }; }, arNextAcct() { const nums = (state.arCustomers || []).map(c => parseInt(c.acct, 10)).filter(n => !isNaN(n)); return String((nums.length ? Math.max(...nums) : 1000) + 1); }, addARCustomer(rec) { if (!rec.name) return { ok: false, msg: "Customer name is required." }; state.arCustomers = state.arCustomers || []; const acct = rec.acct || api.arNextAcct(); if (state.arCustomers.find(c => c.acct === acct)) return { ok: false, msg: `Account ${acct} already exists.` }; state.arCustomers.unshift({ acct, name: rec.name, type: rec.type || "RTL", contact: rec.contact || "", phone: rec.phone || "", addr: rec.addr || "", city: rec.city || "", terms: rec.terms || "Net 30", creditLimit: +rec.creditLimit || 0, taxCode: rec.taxCode || (state.arDefaults && state.arDefaults.defaultTaxCode) || "0100", opened: _iso(new Date()), txns: [], }); logAudit("AR New Account", `${acct} — ${rec.name}`); persist(); notify(); return { ok: true, msg: `Account ${acct} — ${rec.name} created.`, acct }; }, updateARCustomer(acct, patch) { const c = (state.arCustomers || []).find(x => x.acct === acct); if (!c) return { ok: false, msg: "Account not found." }; Object.assign(c, patch); persist(); notify(); return { ok: true, msg: `Account ${acct} updated.` }; }, // Post a manual charge to an account → DR 220, CR offset (+ tax payable) // RC33 (mpx): a SERVER command now — MoneyCore.jsx.txt (loaded after this file) replaces it; this stub only answers when that file is missing arPostCharge(acct, f) { return moneyMissing("An A/R charge"); }, // Apply a payment → DR cash, CR 220. Card payments split out the merchant fee. // RC33 (mpx): a SERVER command now — MoneyCore.jsx.txt (loaded after this file) replaces it; this stub only answers when that file is missing arTakePayment(acct, f) { return moneyMissing("An A/R payment"); }, // Adjustment (debit or credit memo) → DR/CR 220 vs 220 contra/income // RC33 (mpx): a SERVER command now — MoneyCore.jsx.txt (loaded after this file) replaces it; this stub only answers when that file is missing arAdjust(acct, f) { return moneyMissing("An A/R memo"); }, // Month-end finance-charge run. Charges past-due balances per ARDFT. // RC33 (mpx): a SERVER command now — MoneyCore.jsx.txt (loaded after this file) replaces it; this stub only answers when that file is missing arAssessFinanceCharges() { return moneyMissing("The finance-charge run"); }, // Generate collection letters for delinquent accounts (logs them) arSendLetters(minBucket) { const order = ["0-30", "31-60", "61-90", "90+"]; const minIdx = order.indexOf(minBucket || "31-60"); const tmpls = state.arLetterTemplates || []; let count = 0; (state.arCustomers || []).forEach(c => { const a = api.arAge(c.acct); // worst (oldest) non-empty bucket let worst = -1; order.forEach((b, i) => { if (a.buckets[b] > 0.005) worst = Math.max(worst, i); }); if (worst < minIdx) return; const bucket = order[worst]; const tmpl = tmpls.find(t => t.bucket === bucket) || tmpls[tmpls.length - 1] || { code: "L1", name: "Notice" }; state.arLetters = state.arLetters || []; state.arLetters.unshift({ acct: c.acct, name: c.name, bucket, template: tmpl.code, templateName: tmpl.name, balance: a.balance, sent: _iso(new Date()) }); count++; }); if (count === 0) return { ok: false, msg: `No accounts at ${minBucket || "31-60"} or worse — no letters generated.` }; logAudit("AR Letters Sent", `${count} letters`); persist(); notify(); return { ok: true, msg: `Generated ${count} collection letter(s).` }; }, arUpdateDefaults(patch) { state.arDefaults = { ...(state.arDefaults || {}), ...patch }; persist(); notify(); return { ok: true, msg: "A/R defaults saved." }; }, // ── Trade appraisals ── addAppraisal(rec) { state.appraisals = state.appraisals || []; const id = api._uid("APR"); // (sales features H7) collision-safe; never "AP-" + clock digits const a = { id, created: _iso(new Date()), appraiser: currentUser.user, ...rec }; state.appraisals.unshift(a); logAudit("Trade Appraisal", `${id} — ${rec.year || ""} ${rec.make || ""} ${rec.model || ""} · ACV ${rec.acv != null ? "$" + rec.acv : "—"}`); persist(); notify(); return { ok: true, msg: `Appraisal ${id} saved.`, id }; }, applyTradeToDeal(dealId, { allowance, acv, payoff, vehicle, vin }) { const d = (state.deals || []).find(x => x.id === dealId); if (!d) return { ok: false, msg: "Deal not found." }; if (allowance != null) d.tradeAllow = +allowance; if (acv != null) d.tradeACV = +acv; if (payoff != null) d.tradePayoff = +payoff; if (vehicle) d.tradeVeh = vehicle; if (vin) d.tradeVin = vin; logAudit("Trade Applied", `${dealId} — allowance $${d.tradeAllow || 0}, ACV $${d.tradeACV || 0}`); persist(); notify(); return { ok: true, msg: `Trade applied to ${dealId}.` }; }, // ── Deals (shared ledger — used by Deals, MO Forms, Trade Appraisal, GL) ── addDeal(rec) { if (!rec || !rec.buyer) return { ok: false, msg: "Buyer name is required." }; state.deals = state.deals || []; const id = rec.id || ("DR-" + String(Date.now()).slice(-7)); const unit = rec.stock ? api._unit(rec.stock) : null; const deal = { id, buyer: rec.buyer, custNum: rec.custNum || "", vehicle: rec.vehicle || (unit ? `${unit.yr} ${unit.mk} ${unit.mdl}`.trim() : ""), stock: rec.stock || "", vin: rec.vin || (unit ? unit.vin || "" : ""), phone: rec.phone || "", email: rec.email || "", street: rec.street || "", city: rec.city || "", state: rec.state || "", zip: rec.zip || rec.postal || "", postal: rec.postal || rec.zip || "", // B-02: New | Used always carried — from the record, else the unit. type: rec.type || api._dealTypeOfUnit(unit), dealType: rec.dealType || "", // "Retail" | "Lease" | "Cash" (set by the desk) salesperson: rec.salesperson || "", spId: rec.spId || "", // B-01: vehicle cost always carried — from the record, else the unit // (cost, then invoice). Left at 0 with a console warning only when the // unit itself has no cost; postDeal refuses to accrue commission then. salePrice: +rec.salePrice || 0, cost: +rec.cost || 0, tax: +rec.tax || 0, docFee: +rec.docFee || 0, titleFee: +rec.titleFee || 0, tradeAllow: +rec.tradeAllow || 0, tradePayoff: +rec.tradePayoff || 0, // Trade carried from the CRM lead / appraisal at "Desk It" (desc = "YEAR MAKE MODEL"). tradeDesc: rec.tradeDesc || "", tradeLender: rec.tradeLender || "", tradeVin: rec.tradeVin || "", tradeMiles: +rec.tradeMiles || 0, tradeApprId: rec.tradeApprId || "", // Protection products chosen on the desk: [{ id, code, name, category, provider, price, cost, termMo }] products: Array.isArray(rec.products) ? rec.products : [], tradeACV: rec.tradeACV != null ? +rec.tradeACV || 0 : (rec.tradeAcv != null ? +rec.tradeAcv || 0 : undefined), cashDown: +rec.cashDown || 0, rebate: +rec.rebate || 0, lender: rec.lender || "", vscSale: +rec.vscSale || 0, vscCost: +rec.vscCost || 0, gapSale: +rec.gapSale || 0, gapCost: +rec.gapCost || 0, amtFinanced: +rec.amtFinanced || 0, status: rec.status || "Working", apr: +rec.apr || 0, term: +rec.term || 0, payment: +rec.payment || 0, fiGross: +rec.fiGross || 0, // ONE PATH: every deal is born from a CRM lead at "Desk It". leadId // links back to the lead; stage walks Desked → Cash | F&I → Posted → Funded. leadId: rec.leadId || "", stage: rec.stage || "Desked", postedToGL: false, created: _iso(new Date()), rev: 1, updatedAt: _iso(new Date()) }; if (deal.tradeACV === undefined) delete deal.tradeACV; api._normalizeDeal(deal); state.deals.unshift(deal); logAudit("New Deal", `${id} — ${rec.buyer}`); persist(); notify(); return { ok: true, msg: `Deal ${id} created.`, id, deal }; }, // RC21 (deals): the money of a POSTED deal is frozen (src/lib/deals-guard.js refuses the save too) — notes, checklists and the // funding status may change; payments / posted figures / fundings are the server's (never written from here); an unposted deal's // figures must be possible (DealCalc.saveProblems = the server's R3 gate: no negative amount financed / balance, the finance terms). DEAL_MONEY_KEYS: ["salePrice", "cost", "costSource", "tax", "docFee", "titleFee", "tradeAllow", "tradePayoff", "tradeACV", "tradeAcv", "rebate", "cashDown", "amtFinanced", "apr", "term", "payment", "lender", "vscSale", "vscCost", "gapSale", "gapCost", "otherSale", "fiGross", "products", "dealType", "type", "stock", "charges", "adjustments", "dueAtSigning", "leaseUpfrontTax", "leaseTaxMethod", "leaseTaxRate", "moneyFactor", "residual", "residualPct", "totalOfPayments", "leaseAssigned", "received", "totalDue", "balanceDue", "fundedActual", "fundedDiff", "fundedDiffReason", "fundedDate", "postedAt", "postedBy", "costAdds"], // RC27 (dsk): the deal's cost lines (Profit panel) — frozen once posted, like the server (lib/deals-guard.js) _dealPatchProblem(d, patch) { const p = patch || {}; for (const k of ["receipts", "posted", "fundings", "postedToGL", "refunds", "corrections", "priorPosts", "heldForCustomer", "heldAcct"]) if (p[k] !== undefined && JSON.stringify(p[k]) !== JSON.stringify(d[k])) return k === "receipts" ? "Payments are recorded with Record Payment on the Cash Deal (the office) — a deal save cannot change them." : (k === "postedToGL" ? "Use Post to Accounting — the server posts the deal." : `"${k}" is written by the server only.`); if (p.stage !== undefined && p.stage !== d.stage && /^(posted|funded)$/i.test(String(p.stage))) return "Use Post to Accounting / Funding — the server sets Posted and Funded."; if ((p.status !== undefined && /^funded/i.test(String(p.status)) && !/^funded/i.test(String(d.status || ""))) || (p.fundingStatus !== undefined && /^funded/i.test(String(p.fundingStatus)) && !/^funded/i.test(String(d.fundingStatus || "")))) return "Use Funding (Reconcile & Fund) — the funded status comes from the lender's money recorded on the server."; const postedLike = window.DealCalc ? window.DealCalc.isPostedDeal(d) : !!d.postedToGL; // posted, or carried as Posted / Funded if (postedLike && !d.imported) { const blank = (v) => v === undefined || v === null || v === ""; const same = (a, b) => (blank(a) && blank(b)) || (typeof a !== "object" && typeof b !== "object" && !isNaN(+a) && !isNaN(+b) && !blank(a) && !blank(b) ? Math.round(+a * 100) === Math.round(+b * 100) : JSON.stringify(a) === JSON.stringify(b)) || ((blank(a) && +b === 0) || (blank(b) && +a === 0)); const changed = api.DEAL_MONEY_KEYS.filter(k => p[k] !== undefined && !same(p[k], d[k])); if (changed.length) return `${d.id} is posted — its figures (${changed.slice(0, 4).join(", ")}${changed.length > 4 ? " …" : ""}) are on the books and can't be changed here. Correcting a posted deal (reverse and re-post) is not part of this release — ask the office to post a correcting journal entry in Accounting.`; } else if (!postedLike && window.DealCalc) { const probs = window.DealCalc.saveProblems(Object.assign({}, d, p)); if (probs.length) return probs[0].msg; } return null; }, updateDeal(id, patch) { const d = (state.deals || []).find(x => x.id === id); if (!d) return { ok: false, msg: "Deal not found." }; const problem = api._dealPatchProblem(d, patch); if (problem) return { ok: false, refused: true, posted: !!d.postedToGL, msg: problem + " Nothing was saved." }; Object.assign(d, patch || {}); if (!d.postedToGL) api._normalizeDeal(d); // (RC21) a posted deal is never re-normalised (its cost / type / ACV are the posted figures) // Revision stamp — the Finance Worksheet mirrors a deal into its form // only when (id, rev) changes, so a remount never clobbers F&I's edits. d.rev = (+d.rev || 0) + 1; d.updatedAt = _iso(new Date()); persist(); notify(); return { ok: true, msg: `Deal ${id} updated.`, rev: d.rev }; }, // ════════════════════════════════════════════════════════════════ // ONE PATH — deal stages. CRM lead → "Desk It" creates the deal at // DESKED → the desk pushes it to CASH or F&I → closing POSTS it to // accounting → accounting marks it FUNDED (done). Stored on deal.stage; // dealStage() reconciles legacy records that have no stage yet. // ════════════════════════════════════════════════════════════════ DEAL_STAGES: ["Desked", "Cash", "F&I", "Posted", "Funded"], dealStage(d) { if (!d) return "Desked"; if (/^funded/i.test(String(d.status || "")) || d.stage === "Funded") return "Funded"; if (d.postedToGL) return "Posted"; if (d.stage === "Cash" || d.stage === "F&I") return d.stage; if (/cash/i.test(String(d.dealType || ""))) return "Cash"; if ((+d.amtFinanced || 0) > 0 || d.lender || /lease|retail/i.test(String(d.dealType || ""))) return "F&I"; return "Desked"; }, dealStageIndex(d) { const i = api.DEAL_STAGES.indexOf(api.dealStage(d)); return i < 0 ? 0 : i; }, setDealStage(id, stage) { const d = (state.deals || []).find(x => x.id === id); if (!d) return { ok: false, msg: "Deal not found." }; if (!api.DEAL_STAGES.includes(stage)) return { ok: false, msg: "Unknown stage " + stage }; if (d.postedToGL && (stage === "Desked" || stage === "Cash" || stage === "F&I")) return { ok: false, msg: `${id} is already posted — it can't go back to ${stage}.` }; if ((stage === "Posted" || stage === "Funded") && d.stage !== stage) return { ok: false, msg: stage === "Posted" ? "Use Post to Accounting — the server posts the deal." : "Use Funding (Reconcile & Fund / Mark Funded) — the server records the funding." }; // RC21 (deals) d.stage = stage; if (stage === "Cash") { d.dealType = "Cash"; d.amtFinanced = 0; d.lender = ""; d.apr = 0; d.term = 0; d.payment = 0; } d.rev = (+d.rev || 0) + 1; d.updatedAt = _iso(new Date()); logAudit("Deal Stage", `${id} → ${stage}`); persist(); notify(); return { ok: true, msg: `${id} → ${stage}.`, rev: d.rev }; }, // Everything that must be true before a deal may post to accounting — // ONE gate list shared by the Finance deal, the Cash deal, the Deal // Pipeline and the Dashboard (they used to each check a different subset). // Returns { ok, blocks:[{code,label,detail,hard}] }. hard=true cannot be // overridden; the rest the UI may override with a logged confirm. dealCloseGates(dealId) { const d = (state.deals || []).find(x => x.id === dealId); if (!d) return { ok: false, blocks: [{ code: "NOT_FOUND", label: "Deal not found", hard: true }] }; const blocks = []; if (d.postedToGL) blocks.push({ code: "POSTED", label: `${d.id} is already posted`, hard: true }); if (!d.stock) blocks.push({ code: "NO_UNIT", label: "No vehicle on the deal — pick a unit in Desking", hard: true }); if (!(+d.salePrice > 0)) blocks.push({ code: "NO_PRICE", label: "No sale price — desk the deal first", hard: true }); if (!d.custNum || !api.findCustomer(d.custNum)) blocks.push({ code: "NO_CUSTOMER", label: "Deal is not linked to a customer file (start it from the CRM)", hard: false }); // RC30 (cn): an old number still finds the account const stg = api.dealStage(d); if (stg === "Desked") blocks.push({ code: "NOT_PUSHED", label: "Deal was never pushed to Cash or Finance from the desk", hard: false }); if (stg === "F&I" && !(+d.amtFinanced > 0) && !/lease/i.test(String(d.dealType || ""))) blocks.push({ code: "NO_AMT", label: "Finance deal with $0 amount financed — is this a cash deal?", hard: false }); if (stg === "F&I" && !d.lender) blocks.push({ code: "NO_LENDER", label: "No lender on a finance deal", hard: false }); // K2B-003 (RC18): a cash deal posts only when the recorded payments cover the total due (no cash-deal receivable is mapped). if (!d.postedToGL && api.isCashDeal(d)) { const cf = api.cashDealFigures(d); if (cf.balanceDue > 0.005) blocks.push({ code: "BALANCE_DUE", label: `Record the customer's payment before posting this cash deal (balance due $${api._m2(cf.balanceDue)})`, hard: true }); else if (cf.balanceDue < -0.005) blocks.push({ code: "OVERPAID", label: `The payments recorded are $${api._m2(-cf.balanceDue)} more than this cash deal's total due — correct them before posting`, hard: true }); api._cashPostProblems(d).forEach(x => blocks.push({ code: x.code, label: x.msg, hard: true })); } // RC21 (deals): everything the SERVER would refuse (DealCalc.postPlan — the server's own calculation — on this station's copies of // the deal, its unit and its recorded payments): hard blocks, said BEFORE posting (a trade ACV of $0, no vehicle cost, a stale // amount financed / payment, no lender …). A soft block the server would refuse becomes hard. The server re-checks when it posts. if (!d.postedToGL) { try { const pv = api.dealPostPreview(d.id); if (pv && !pv.ok) pv.problems.forEach(x => { const code = String(x.code || "SERVER").toUpperCase(); const had = blocks.find(b => b.code === code); if (had) had.hard = true; else blocks.push({ code, label: x.msg, hard: true }); }); } catch (e) {} } try { const cmp = api.dealCompliance ? api.dealCompliance(d.id) : { canClose: true }; if (cmp && cmp.found && !cmp.canClose) blocks.push({ code: "COMPLIANCE", label: `Compliance risk ${cmp.score} (max ${cmp.threshold})`, detail: (cmp.open || []).filter(i => !i.auto).map(i => i.label).join(" · "), hard: false }); } catch (e) {} try { const tb = (api.truthBox && d.stock) ? api.truthBox(d.stock) : { canSell: true }; if (tb && tb.found && !tb.canSell) blocks.push({ code: "TRUTH_BOX", label: `Stk ${d.stock} is not retail-ready`, detail: (tb.reasons || []).join(" · "), hard: false }); } catch (e) {} try { const chk = (api.unitOpenItems && d.stock) ? api.unitOpenItems(d.stock, { exceptDeal: d.id }) : { clean: true }; if (chk && !chk.clean) blocks.push({ code: "OPEN_ITEMS", label: `Stk ${d.stock} has open ROs / parts tickets`, detail: (chk.items || []).map(x => x.label).join(" · "), hard: false }); } catch (e) {} return { ok: !blocks.some(b => b.hard), blocks, deal: d }; }, // ════════════════════════════════════════════════════════════════ // DEAL COMPLIANCE — checklist + risk score. Each item carries risk // points charged when it's NOT satisfied. Some items auto-detect from // deal data (trade payoff, CIT aging); the rest are manual toggles in // deal.compliance{}. Score > THRESHOLD blocks the deal from closing. // ════════════════════════════════════════════════════════════════ COMPLIANCE_THRESHOLD: 50, // ════════════════════════════════════════════════════════════════ // PAY-PLAN ENGINE — live salesperson commission off the deal gross. // RC28 (fx, the reviewer's finding 3): the plan is the SERVER's — ONE stored, versioned configuration with an effective date // (GET / POST /api/pay-plan, audited). This station reads it (payPlanConfig) and never keeps or posts its own: the desk preview, // the posting, the chargebacks and this commission screen all figure with the same plan (DealCalc.profitPlan, integer cents). // A POSTED deal keeps the plan version it was posted with (deal.posted.payPlan). Commissionable gross = front gross after pack, // over-allowance and the deal's FRONT cost lines; mini when below the floor; product bonuses; funded-only payout gate. // ════════════════════════════════════════════════════════════════ PAY_PLAN_DEFAULT: { usedPct: 0.25, newPct: 0.20, miniFloor: 150, vscBonus: 75, gapBonus: 50, appearanceBonus: 100, fundedOnly: true, packCounts: true }, _payPlan: { at: 0, effective: null, versions: [], pending: [], mayEdit: false, stored: false, msg: null, loading: false }, payPlanCached() { return api._payPlan; }, async loadPayPlan() { if (!AUTH_TOKEN || __offlineByChoice) return { ok: false, msg: "The pay plan is kept on the server — connect first." }; api._payPlan.loading = true; try { const r = await apiGet("/pay-plan"); api._payPlan = { at: Date.now(), effective: r.effective || null, versions: r.versions || [], pending: r.pending || [], mayEdit: !!r.mayEdit, stored: !!r.stored, today: r.today, fields: r.fields || [], msg: null, loading: false }; notify(); return { ok: true }; } catch (e) { const b = (e && e.body) || {}; api._payPlan = Object.assign({}, api._payPlan, { at: Date.now(), loading: false, msg: b.error || (e && e.message) || "" }); return { ok: false, msg: b.error || "Could not load the pay plan." }; } }, /** the plan in effect (the calculation's shape: usedPct / newPct fractions, dollars) + its version — the server's, never this station's */ payPlanConfig() { const pp = api._payPlan; if (!pp.at && !pp.loading && AUTH_TOKEN) { try { api.loadPayPlan(); } catch (e) {} } const e = pp.effective; if (e && e.calc) return Object.assign({}, api.PAY_PLAN_DEFAULT, e.calc, { version: e.version, effectiveOn: e.effectiveOn, stored: !!e.stored }); return Object.assign({}, api.PAY_PLAN_DEFAULT, { version: 0, loading: true }); }, /** the plan a deal is figured with: a POSTED deal's own version (posted.payPlan; posted before RC28 = the shipped default), else today's */ payPlanForDeal(d) { const pp = d && d.posted && d.posted.payPlan; if (pp && pp.plan && window.DealCalc && window.DealCalc.payPlanCalc) return Object.assign({}, api.PAY_PLAN_DEFAULT, window.DealCalc.payPlanCalc(pp.plan), { version: pp.version, effectiveOn: pp.effectiveOn, posted: true }); if (d && d.postedToGL && d.posted) return Object.assign({}, api.PAY_PLAN_DEFAULT, { version: 0, posted: true, note: "posted before RC28 — the shipped default pay plan" }); return api.payPlanConfig(); }, /** save a NEW version on the server (the owner, Admin / F&I or the Office Manager). patch: any of usedPct / newPct (fractions) or usedPctText / newPctText ("30"), miniFloor, vscBonus, gapBonus, appearanceBonus (dollars), packCounts, fundedOnly; o.effectiveOn, o.reason */ async setPayPlanConfig(patch, o) { o = o || {}; patch = patch || {}; if (!AUTH_TOKEN || __offlineByChoice) return { ok: false, msg: "The pay plan is kept on the server — connect first." }; const cur = api.payPlanConfig(); const pct = (txt, frac, was) => (txt !== undefined && txt !== null && String(txt).trim() !== "" ? String(txt).replace(/%/g, "").trim() : String(Math.round(((frac !== undefined && frac !== null ? +frac : +was) || 0) * 10000) / 100)); const usd = (v, was) => (v !== undefined && v !== null && String(v).trim() !== "" ? String(v).replace(/[$,\s]/g, "") : (+was || 0).toFixed(2)); const plan = { newPct: pct(patch.newPctText, patch.newPct, cur.newPct), usedPct: pct(patch.usedPctText, patch.usedPct, cur.usedPct), miniFloor: usd(patch.miniFloor, cur.miniFloor), vscBonus: usd(patch.vscBonus, cur.vscBonus), gapBonus: usd(patch.gapBonus, cur.gapBonus), appearanceBonus: usd(patch.appearanceBonus, cur.appearanceBonus), packCounts: patch.packCounts !== undefined ? !!patch.packCounts : cur.packCounts !== false, fundedOnly: patch.fundedOnly !== undefined ? !!patch.fundedOnly : cur.fundedOnly !== false }; const t = new Date(); const today = t.getFullYear() + "-" + String(t.getMonth() + 1).padStart(2, "0") + "-" + String(t.getDate()).padStart(2, "0"); const key = o.idemKey || api._newIdemKey("pp"); const r = await api._command("/pay-plan", { effectiveOn: o.effectiveOn || today, plan, reason: o.reason || "Pay plan changed on the Pay Plan page" }, key, "/pay-plan/op/"); if (!r.ok) { if (r.unknown) return { ok: false, unknown: true, key, msg: "The server did not answer. Open the Pay Plan page again before saving — the version list shows whether it went through." }; const b = (r.err && r.err.body) || {}; return { ok: false, key, code: b.code, msg: b.error || (r.err && r.err.message) || "The server refused the pay plan." }; } logAudit("Pay Plan Changed", `version ${r.res.version} effective ${r.res.effectiveOn}: ` + Object.keys(plan).map(k => k + "=" + plan[k]).join(", ")); await api.loadPayPlan(); return { ok: true, version: r.res.version, msg: r.res.message || "Pay plan saved." }; }, /** RC28 (fx, gate 2b): the PROFIT of a deal — ONE reading for the Deal Pipeline, the vehicle history, the dashboard and the commission screen, exactly the desk's / the posting's (DealCalc.profitPlan): front gross = price − vehicle cost − over-allowance; net front = front − pack − the FRONT cost lines; net back = F&I gross − the BACK cost lines; commission by the deal's pay plan. A POSTED deal is read from what the server posted (posted.figures). Cents. */ dealGross(d) { const DC = window.DealCalc; if (!d || !DC || !DC.profitPlan) return null; const c = (v) => { const x = DC.toCents(v); return isFinite(x) ? x : 0; }; const pf = d.posted && d.posted.figures ? d.posted.figures : null; const unit = api._unit(d.stock); const plan = api.payPlanForDeal(d); const acv = DC.acvOf(d); const costC = pf ? (pf.costCents || 0) : (unit ? (DC.vehicleCost(d, unit).cents || c(d.cost)) : c(d.cost)); const overC = pf ? (pf.overAllowCents || 0) : (acv !== null && isFinite(acv) && acv > 0 ? c(d.tradeAllow) - acv : 0); const packC = pf && pf.costAdds ? (pf.costAdds.packCents || 0) : (plan.packCounts !== false ? Math.max(0, unit ? c(unit.pack) : 0) : 0); const fiC = pf ? (pf.fiGrossCents || 0) : c(d.fiGross); const ca = DC.costAddsOf(d); const p = DC.profitPlan({ priceCents: pf ? pf.priceCents : c(d.salePrice), costCents: costC, overCents: overC, packCents: packC, fiGrossCents: fiC, isNew: /new/i.test(d.type || "") || (unit && /^n/i.test(String(unit.type || ""))), ca, payPlan: plan }); if (d.posted && isFinite(d.posted.commissionCents) && d.posted.commissionCents > 0) p.commissionCents = d.posted.commissionCents; // what the posting booked p.planVersion = plan.version; p.posted = !!pf; return p; }, dealPayPlan(dealId) { const d = (state.deals || []).find(x => x.id === dealId); if (!d) return { found: false }; const cfg = api.payPlanForDeal(d); // Imported old-DMS history: commission was paid by the old system — nothing is payable here. if (d.imported) return { found: true, imported: true, dealId, salesperson: d.salesperson || "—", spId: d.spId || "", isNew: /new/i.test(d.type || ""), pack: 0, tradeOverallow: 0, frontGross: 0, pct: 0, baseComm: 0, isMini: false, miniFloor: cfg.miniFloor, commission: 0, bonuses: [], bonusTotal: 0, total: 0, funded: true, eligible: false, fundedOnly: cfg.fundedOnly, status: d.status, postedToGL: !!d.postedToGL, note: "Imported from the old DMS — commission was paid there." }; // RC28 fix round (audit FU10): an UNWOUND deal (posted, unwound, not posted again — read from its server history) pays nothing: the // unwind reversed its commission accrual (and charged back a paid one) if (window.DealCalc && window.DealCalc.isUnwoundDeal(d)) return { found: true, unwound: true, dealId, salesperson: d.salesperson || "—", spId: d.spId || "", isNew: /new/i.test(d.type || ""), pack: 0, tradeOverallow: 0, frontAdds: 0, frontGross: 0, frontGrossCents: 0, pct: 0, baseComm: 0, isMini: false, miniFloor: cfg.miniFloor, commission: 0, commissionCents: 0, bonuses: [], bonusTotal: 0, total: 0, funded: false, eligible: false, fundedOnly: cfg.fundedOnly, status: d.status, postedToGL: false, note: "Unwound — the commission was reversed (a commission already paid is charged back on the next payroll)." }; // RC28 (fx): the SAME integer-cents calculation as the desk and the posting (DealCalc.profitPlan with the server's plan) const g = api.dealGross(d); if (!g) return { found: true, dealId, salesperson: d.salesperson || "—", commission: 0, total: 0, bonuses: [], bonusTotal: 0, eligible: false, note: "The deal calculation is not loaded." }; const isNew = /new/i.test(d.type || ""); const pack = g.packCents / 100, frontAdds = g.frontAddsCents / 100; const tradeOverallow = (g.frontGrossCents - g.netFrontCents - g.packCents - g.frontAddsCents) / 100; const pct = isNew ? cfg.newPct : cfg.usedPct; const baseComm = g.baseCommissionCents / 100; const commission = g.commissionCents / 100; const isMini = !!g.isMini; // Product bonuses const bonuses = []; // RC23 (corr): a product cancelled after the sale earns no bonus (its chargeback is on the commission list) const liveProds = Array.isArray(d.products) && d.products.length ? d.products.filter(p => p && !p.cancelled) : null; const isGapP = (p) => /\bgap\b/i.test(String(p.category || "") + " " + (p.code || "") + " " + (p.name || "")); const hasVsc = liveProds ? liveProds.some(p => !isGapP(p)) : (+d.vscSale || 0) > 0, hasGap = liveProds ? liveProds.some(isGapP) : (+d.gapSale || 0) > 0; if ((+d.vscSale || 0) > 0 && hasVsc) bonuses.push({ label: "Service contract (VSC)", amount: cfg.vscBonus }); if ((+d.gapSale || 0) > 0 && hasGap) bonuses.push({ label: "GAP", amount: cfg.gapBonus }); if ((+d.appearanceSale || 0) > 0) bonuses.push({ label: "Appearance / paint", amount: cfg.appearanceBonus }); const bonusTotal = bonuses.reduce((s, b) => s + b.amount, 0); const total = Math.round((commission + bonusTotal) * 100) / 100; const funded = window.DealCalc.isFundedWord(d.status); // RC28 fix round (attack P1): the server's reading — "Delivered Not Funded" is NOT funded const eligible = !cfg.fundedOnly || funded; return { found: true, dealId, salesperson: d.salesperson || "—", spId: d.spId || "", isNew, pack, tradeOverallow, frontAdds, frontGross: g.netFrontCents / 100, frontGrossCents: g.netFrontCents, pct, baseComm, isMini, miniFloor: cfg.miniFloor, commission, commissionCents: g.commissionCents, bonuses, bonusTotal, total, funded, eligible, fundedOnly: cfg.fundedOnly, planVersion: cfg.version, planPosted: !!cfg.posted, status: d.status, postedToGL: !!d.postedToGL, }; }, setDealCompliance(dealId, patch) { const d = (state.deals || []).find(x => x.id === dealId); if (!d) return { ok: false, msg: "Deal not found." }; d.compliance = { ...(d.compliance || {}), ...patch }; persist(); notify(); return { ok: true, msg: "Compliance updated." }; }, dealCompliance(dealId) { const d = (state.deals || []).find(x => x.id === dealId); if (!d) return { found: false, score: 0, canClose: false, items: [] }; // Imported old-DMS history was closed there — no checklist, no risk score, never "blocked". if (d.imported) return { found: true, imported: true, dealId, score: 0, threshold: api.COMPLIANCE_THRESHOLD, canClose: true, level: "clear", items: [], open: [], note: "Imported from the old DMS — closed there." }; const c = d.compliance || {}; const hasTrade = (+d.tradeAllow || 0) > 0 || (+d.tradePayoff || 0) > 0; const hasPayoff = (+d.tradePayoff || 0) > 0; const isUsed = /used/i.test(d.type || ""); const citAge = d.delivered ? daysSince(d.delivered) : 0; // auto: true = satisfied. manual items read from c[key] (default false). const A = (v) => ({ auto: true, ok: !!v }); const M = (key) => ({ ok: !!c[key], key }); const defs = [ { key: "idVerified", label: "ID verified", pts: 20, ...M("idVerified") }, { key: "ofac", label: "OFAC / watch-list checked", pts: 20, ...M("ofac") }, { key: "creditAppSigned", label: "Credit application signed", pts: 50, ...M("creditAppSigned") }, { key: "privacyNotice",label: "Privacy notice", pts: 10, ...M("privacyNotice") }, { key: "riskBased", label: "Risk-based pricing notice", pts: 10, ...M("riskBased") }, { key: "odometer", label: "Odometer statement", pts: 25, ...M("odometer") }, { key: "titleApp", label: "Title application completed", pts: 30, ...M("titleApp") }, { key: "titleInHand", label: "Title in hand (or app on file)", pts: 30, ...M("titleInHand") }, { key: "tradeTitle", label: "Trade title received", pts: 25, applicable: hasTrade, ...M("tradeTitle") }, { key: "payoffAuth", label: "Trade payoff authorization sent",pts: 25, applicable: hasPayoff, ...M("payoffAuth") }, { key: "safetyInsp", label: "Safety inspection on file", pts: 50, applicable: isUsed, ...M("safetyInsp") }, { key: "buyersGuide", label: "Buyer's Guide / AS-IS box", pts: 35, applicable: isUsed, ...M("buyersGuide") }, { key: "weOwe", label: "We-Owe completed", pts: 15, ...M("weOwe") }, { key: "insurance", label: "Insurance verified", pts: 20, ...M("insurance") }, { key: "lenderDocs", label: "Lender documents", pts: 20, applicable: !!d.lender, ...M("lenderDocs") }, { key: "productContracts", label: "Product contracts (VSC/GAP)",pts: 10, applicable: (+d.vscSale||0)>0||(+d.gapSale||0)>0, ...M("productContracts") }, { key: "deliveryConf", label: "Delivery confirmation", pts: 15, ...M("deliveryConf") }, // auto-detected risk { key: "citAge", label: "CIT funded within 14 days", pts: 20, ...A(!(citAge >= 14 && d.status !== "Funded")), auto: true, note: citAge >= 14 && d.status !== "Funded" ? `Delivered ${citAge}d ago, not funded` : "" }, ]; const items = defs.filter(x => x.applicable !== false); const score = items.filter(x => !x.ok).reduce((s, x) => s + x.pts, 0); const open = items.filter(x => !x.ok); return { found: true, dealId, score, threshold: api.COMPLIANCE_THRESHOLD, canClose: score <= api.COMPLIANCE_THRESHOLD, level: score === 0 ? "clear" : score <= api.COMPLIANCE_THRESHOLD ? "caution" : "blocked", items, open, }; }, // ════════════════════════════════════════════════════════════════ // CASH COMMAND CENTER — live operating-cash forecast from the ledger. // Inflows: CIT (delivered-not-funded deals), warranty receivable, // finance reserve, A/R. Outflows: trade payoffs due, open A/P, floorplan, // sales tax payable, payroll accrual (commissions payable). // ════════════════════════════════════════════════════════════════ cashCommand() { const today = state.bankRec ? (+state.bankRec.bookBalance || 0) : 0; // Inflows const citDeals = (state.deals || []).filter(d => d.status === "Delivered Not Funded" && d.postedToGL); const citExpected = citDeals.reduce((s, d) => s + (+d.amtFinanced || 0), 0); const warrantyRec = (state.warrantyReceivables || []).filter(w => w.submitted && !w.paid && w.status !== "Debited") .reduce((s, w) => s + wOpenOf(w), 0); const factoryRec = (state.factoryReceivables || []).filter(f => !f.paid).reduce((s, f) => s + (+f.amount || 0), 0); const arRec = (state.arCustomers || []).reduce((s, a) => s + Math.max(0, +a.balance || 0), 0); // Outflows const payoffsDue = (state.deals || []).filter(d => (+d.tradePayoff || 0) > 0 && !d.payoffSent) .reduce((s, d) => s + (+d.tradePayoff || 0), 0); const apOpen = (state.payables || []).filter(p => p.status === "Open").reduce((s, p) => s + (+p.amount || 0), 0); const taxDue = state.bankRec ? 0 : 0; const commPayable = (state.commissions || []).filter(c => !c.paid).reduce((s, c) => s + (+c.amount || c.commission || 0), 0); const floorDue = (state.floorplanPayoffsDue || []).reduce((s, f) => s + (+f.amount || 0), 0); const inflows = [ { label: "Contracts in transit (CIT)", amount: citExpected, when: 7, kind: "in", detail: citDeals.length + " delivered-not-funded" }, { label: "Warranty receivable (GM)", amount: warrantyRec, when: 21, kind: "in" }, { label: "Factory receivable", amount: factoryRec, when: 14, kind: "in" }, { label: "Customer A/R", amount: arRec, when: 30, kind: "in" }, ].filter(x => x.amount > 0); const outflows = [ { label: "Trade payoffs due", amount: payoffsDue, when: 3, kind: "out" }, { label: "Accounts payable", amount: apOpen, when: 14, kind: "out" }, { label: "Floorplan curtailment", amount: floorDue, when: 10, kind: "out" }, { label: "Commissions payable", amount: commPayable, when: 7, kind: "out" }, ].filter(x => x.amount > 0); const within = (days, arr) => arr.filter(x => x.when <= days).reduce((s, x) => s + x.amount, 0); const pos = (days) => today + within(days, inflows) - within(days, outflows); const net7 = pos(7), net14 = pos(14), net30 = pos(30); // Safe-to-spend: cash on hand minus near-term (7-day) committed outflows, // not counting inflows that haven't funded yet. Floor at 0. const committed7 = within(7, outflows); const safeToSpend = Math.max(0, today - committed7); const tone = safeToSpend > today * 0.4 ? "green" : safeToSpend > 0 ? "yellow" : "red"; return { today, inflows, outflows, net7, net14, net30, safeToSpend, tone, totalIn: inflows.reduce((s, x) => s + x.amount, 0), totalOut: outflows.reduce((s, x) => s + x.amount, 0) }; }, // "Why did cash change?" — net GL movement on the bank cash account over N // days, grouped by source, from posted journal entries. cashMovement(days) { const since = Date.now() - (days || 1) * 86400000; const bankAcct = _checkCfg().bankAcct || ACCT.cashBank; const groups = {}; (state.journalEntries || []).forEach(j => { const t = j.ts || (j.date ? Date.parse(j.date) : 0); if (t && t < since) return; (j.lines || []).forEach(l => { if (String(l.acct) !== String(bankAcct) && String(l.acct) !== "202" && String(l.acct) !== "201") return; const delta = (+l.dr || 0) - (+l.cr || 0); if (!delta) return; const src = j.src || "Other"; groups[src] = (groups[src] || 0) + delta; }); }); const rows = Object.keys(groups).map(k => ({ src: k, amount: +groups[k].toFixed(2) })) .sort((a, b) => Math.abs(b.amount) - Math.abs(a.amount)); const net = rows.reduce((s, r) => s + r.amount, 0); return { days: days || 1, net: +net.toFixed(2), rows }; }, // ── Customers (shared ledger; keyed by custNum = THE CUSTOMER # — RC30 (cn): the phone WITHOUT the area code, 7 digits; a customer with no // phone gets a number from the server (1000001 upward); the rule is CustNum.jsx.txt == the server's lib/custnum.js) ── customers: () => state.customers || {}, /** RC30 (cn): the customer # a phone makes ("(573) 555-0123" → "5550123"), or "" */ custNumFor(phone) { const CNx = window.CustNum; return CNx ? (CNx.custNumFromPhone(phone).num7 || "") : String(phone || "").replace(/\D/g, "").slice(-7); }, /** RC30 (cn): find a customer by its number OR an old number (alias) OR a 10-digit phone; null when none */ findCustomer(num) { const m = state.customers || {}; const t = String(num === undefined || num === null ? "" : num).trim(); if (!t) return null; if (m[t]) return m[t]; const d = t.replace(/\D/g, ""); if (d.length === 10 && m[d.slice(-7)]) return m[d.slice(-7)]; for (const k of Object.keys(m)) { const c = m[k]; if (c && Array.isArray(c.aliases) && c.aliases.some((a) => String(a) === t || String(a) === d)) return c; } return null; }, /** RC31 (fx31, RC30 acceptance F6 — owner decision: "every search box finds the customer / record by an old 10-digit number or a phone"): * ONE reading for every search box that searches customers or what points at them (deals, repair orders, body-shop jobs, appointments, leads, * appraisals, A/R, messaging, statements) — the LIVE customer numbers a typed search names, in CustNum.lookupOrder's order (== the server's * /api/customers/resolve): the number itself, an OLD number (an alias the account carries: the 10-digit pre-RC30 number, a folded household's, * the owner's renumber), the digits of a punctuated number, a 10-digit phone in any format (the customer whose phone it is, else the account its * last 7 digits name). Words and short numbers name nothing here (each box keeps its own text search). When the station holds no answer for a * complete number, the server is asked ONCE (DMS.resolveCustNum — the aliases a station may not hold) and the boxes re-render with its answer. */ custNumsFor(q) { const t = String(q === undefined || q === null ? "" : q).trim(); if (!t || !/^[\d\s().+\-#]+$/.test(t)) return []; const dd = t.replace(/\D/g, ""); if (!(dd.length === 7 || dd.length === 10 || (dd.length === 11 && dd[0] === "1"))) return []; const ix = custQueryIndex(); const hit = ix.memo.get(t); if (hit) return hit; const m = state.customers || {}; const CNx = window.CustNum; let out = []; // RC31 fix round (fx31 checkers, attacker R2 / P1): the tiers of CustNum.lookupOrder — a live number; then the OLD numbers and the customers whose // phone it IS, together (a search lists BOTH: the live customer whose own phone was typed AND the account that number was the old number of — // the phone's own customer first); only when neither exists, the account the last 7 digits name (a suggestion) const steps = CNx && CNx.lookupOrder ? CNx.lookupOrder(t) : [{ kind: "number", value: t, tier: "number" }]; const tierOf = (st) => st.tier || (st.kind === "number" ? "number" : "exact"); for (const st of steps) { if (tierOf(st) === "number" && m[st.value]) { out = [st.value]; break; } } if (!out.length) { const owners = [], olds = []; for (const st of steps) { if (tierOf(st) !== "exact") continue; if (st.kind === "phone" && ix.phone.has(st.value)) ix.phone.get(st.value).forEach((c) => { if (owners.indexOf(c) === -1) owners.push(c); }); if (st.kind === "alias" && ix.alias.has(st.value) && !olds.length) olds.push(ix.alias.get(st.value)); } out = owners.concat(olds.filter((c) => owners.indexOf(c) === -1)); } if (!out.length) for (const st of steps) { if (tierOf(st) !== "last7") continue; if (st.kind === "number" && m[st.value]) { out = [st.value]; break; } if (st.kind === "alias" && ix.alias.has(st.value)) { out = [ix.alias.get(st.value)]; break; } } if (!out.length && _custQ.server.has(t)) out = _custQ.server.get(t) ? [_custQ.server.get(t)] : []; else if (!out.length && !_custQ.server.has(t) && api.can && api.can("customers")) { _custQ.server.set(t, null); // asked once per typed number api.resolveCustNum(t).then((r) => { if (r && r.found && r.custNum) { _custQ.server.set(t, String(r.custNum)); _custQ.ver++; notify(); } }).catch(() => {}); } ix.memo.set(t, out); return out; }, /** RC31 (fx31, F6): does a record that carries customer # `num` (a live OR an old number) — or one of `phones` — belong to what `q` names? */ custQueryHit(q, num, phones) { const CNx = window.CustNum; const nums = api.custNumsFor(q); if (nums.length) { const ix = custQueryIndex(); if (num !== undefined && num !== null && String(num).trim()) { const n = String(num).trim(); const live = (state.customers || {})[n] ? n : (ix.alias.get(n) || n); if (nums.indexOf(live) !== -1) return true; } // a record that carries only a phone / an address (a message, a consent): the account that phone belongs to if (phones && phones.length && CNx) for (const p of phones) { const p10 = CNx.phone10(p); if (p10 && (ix.phone.get(p10) || []).some((c) => nums.indexOf(c) !== -1)) return true; } } if (phones && phones.length && CNx) { const p10 = CNx.phone10(q); if (p10 && phones.some((p) => CNx.phone10(p) === p10)) return true; } return false; }, /** RC30 (cn): what saving this new customer would meet: { kind: 'ok' | 'household' | 'ask_office' | 'exists', existing, custNum } */ custNumCollision(rec) { const CNx = window.CustNum; const m = state.customers || {}; const cn = String((rec && rec.custNum) || "").trim(); const ex = cn ? m[cn] : null; if (!ex) return { kind: "ok", custNum: cn }; if (!CNx) return { kind: "exists", existing: ex, custNum: cn }; const who = { first: String(rec.firstName || ""), last: String(rec.lastName || "") }; if (CNx.hasName(ex, who.first, who.last) || (!who.first && !who.last)) return { kind: "exists", existing: ex, custNum: cn }; const rel = CNx.phoneRelation(rec, ex); return { kind: rel === "different" ? "ask_office" : "household", existing: ex, custNum: cn }; }, addCustomer(rec) { if (!rec || !rec.custNum) return { ok: false, msg: "Customer # is required (it comes from the phone; a customer with no phone gets a number from K2B)." }; const CNx = window.CustNum; if (CNx) { const prob = CNx.shapeProblem(rec.custNum); if (prob) return { ok: false, msg: prob }; const derived = CNx.numberOfRecord(rec); if (derived && derived !== String(rec.custNum) && !CNx.isIssued(rec.custNum)) return { ok: false, msg: `The customer # is the phone without the area code: ${CNx.fmtPhone(CNx.phoneOfRecord(rec))} → ${derived}.` }; } state.customers = state.customers || {}; if (state.customers[rec.custNum]) return { ok: false, code: "exists", msg: `Customer ${rec.custNum} already exists.` }; const r2 = Object.assign({}, rec, { saved: _iso(new Date()) }); if (CNx) { r2.names = CNx.namesOf(r2); if (!Array.isArray(r2.aliases)) r2.aliases = []; } state.customers[rec.custNum] = r2; logAudit("New Customer", `${rec.custNum} — ${[rec.lastName, rec.firstName].filter(Boolean).join(", ")}`); persist(); notify(); return { ok: true, msg: `Customer ${rec.custNum} added.` }; }, upsertCustomer(rec) { if (!rec || !rec.custNum) return { ok: false, msg: "Customer # required." }; state.customers = state.customers || {}; const live = api.findCustomer(rec.custNum); // RC30 (cn): a save under an old number lands on the live account const key = live ? String(live.custNum) : String(rec.custNum); const existing = state.customers[key] || {}; const next = { ...existing, ...rec, custNum: key, saved: _iso(new Date()) }; if (window.CustNum) for (const k of window.CustNum.SERVER_FIELDS) { if (existing[k] !== undefined) next[k] = existing[k]; else if (k !== "names") delete next[k]; } // the server owns these state.customers[key] = next; persist(); notify(); return { ok: true, custNum: key }; }, /** RC30 (cn): a second person on the account (same phone — one household); the server records it */ async addHouseholdName(custNum, person) { const cn = String(custNum || "").trim(); const p = person || {}; const key = "cn-names-" + cn + "-" + Date.now().toString(36); const r = await api._command("/customers/" + encodeURIComponent(cn) + "/names", { firstName: p.firstName || p.first || "", lastName: p.lastName || p.last || "", role: p.role || "household" }, key, "/customers/op/"); if (!r.ok) return { ok: false, msg: (r.err && r.err.body && r.err.body.error) || "Could not add the name." }; if (r.res && r.res.record) { state.customers = state.customers || {}; state.customers[cn] = r.res.record; lastSent.set(recKey("customers", cn), JSON.stringify(r.res.record)); if (r.res.records && r.res.records[0]) lastRev.set(recKey("customers", cn), Number(r.res.records[0].rev)); persist(); notify(); } return { ok: true, custNum: cn, msg: (r.res && r.res.message) || "Added." }; }, /** RC30 (cn) fix round: the office takes a wrongly added person OFF a household account (audited; the server refuses the only name) */ async removeHouseholdName(custNum, person, reason) { const cn = String(custNum || "").trim(); const p = person || {}; const key = "cn-names-rm-" + cn + "-" + Date.now().toString(36); const r = await api._command("/customers/" + encodeURIComponent(cn) + "/names/remove", { firstName: p.firstName || p.first || "", lastName: p.lastName || p.last || "", reason: reason || "" }, key, "/customers/op/"); if (!r.ok) return { ok: false, msg: (r.err && r.err.body && r.err.body.error) || "Could not take the person off the account." }; if (r.res && r.res.record) { state.customers = state.customers || {}; state.customers[cn] = r.res.record; lastSent.set(recKey("customers", cn), JSON.stringify(r.res.record)); if (r.res.records && r.res.records[0]) lastRev.set(recKey("customers", cn), Number(r.res.records[0].rev)); persist(); notify(); } return { ok: true, custNum: cn, msg: (r.res && r.res.message) || "Removed." }; }, /** RC30 (cn) fix round: the office gives a person on a household account a customer # of their own (from their own phone when its 7 digits * are free, else a number K2B issues); the account's own phone is refused by the rule (people on one phone share one account) */ async splitCustomer(custNum, person, phone, reason) { const cn = String(custNum || "").trim(); const p = person || {}; const key = "cn-split-" + cn + "-" + Date.now().toString(36); const r = await api._command("/customers/" + encodeURIComponent(cn) + "/split", { firstName: p.firstName || p.first || "", lastName: p.lastName || p.last || "", phone: phone || "", reason: reason || "" }, key, "/customers/op/"); if (!r.ok) return { ok: false, msg: (r.err && r.err.body && r.err.body.error) || "Could not split the account." }; await loadFromServer({ replace: true }); notify(); return { ok: true, from: r.res.from, to: r.res.to, msg: r.res.message }; }, /** RC30 (cn): a customer # from the server for a customer WITHOUT a phone (or a second person on another area code the office keeps separate) */ async issueCustNum(opts) { const o = opts || {}; const key = "cn-issue-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); const r = await api._command("/customers/issue-number", { reason: o.reason || "no phone", collidesWith: o.collidesWith || undefined, name: o.name || undefined }, key, "/customers/op/"); if (!r.ok) return { ok: false, msg: (r.err && r.err.body && r.err.body.error) || "K2B could not issue a customer number right now." }; return { ok: true, custNum: r.res.custNum, msg: r.res.message }; }, /** RC30 (cn): the server's answer for a number or an old number (aliases the station may not hold yet) */ async resolveCustNum(num) { try { return await apiGet("/customers/resolve?num=" + encodeURIComponent(String(num || "").trim())); } catch (e) { return { ok: false, found: false, error: (e && e.message) || "" }; } }, /** RC30 (cn): the owner changes a customer number (audited; the old number stays as an alias) */ async renumberCustomer(custNum, newNum, reason) { const key = "cn-renum-" + Date.now().toString(36); const r = await api._command("/customers/renumber", { custNum, newNum: newNum || "issue", reason, confirm: "RENUMBER" }, key, "/customers/op/"); if (!r.ok) return { ok: false, msg: (r.err && r.err.body && r.err.body.error) || "Could not renumber." }; await loadFromServer({ replace: true }); notify(); return { ok: true, from: r.res.from, to: r.res.to, msg: r.res.message }; }, /** RC30 (cn): the office's to-settle list and its decision */ async custSettleList(status) { try { return await apiGet("/customers/settle" + (status ? "?status=" + encodeURIComponent(status) : "")); } catch (e) { return { ok: false, items: [], error: (e && e.message) || "" }; } }, async custSettle(id, decision) { const key = "cn-settle-" + Date.now().toString(36); const r = await api._command("/customers/settle/" + encodeURIComponent(id), { decision }, key, "/customers/op/"); if (!r.ok) return { ok: false, msg: (r.err && r.err.body && r.err.body.error) || "Could not settle." }; await loadFromServer({ replace: true }); notify(); return { ok: true, msg: r.res.message }; }, deleteCustomer(custNum) { if (!api.isOwner()) return { ok: false, msg: "Only the dealership owner can delete a customer (a customer number is kept for good). Ask the owner to renumber it instead." }; // RC30 (cn) if (state.customers && state.customers[custNum]) { delete state.customers[custNum]; persist(); notify(); } return { ok: true }; }, // Bulk import customers (from parsed PDF/Word/Excel/OCR docs). RC30 (cn): the number comes from the phone (7 digits); a row with no phone // and no 7-digit number is NOT given a made-up key — it is reported (a number for it comes from the server through the Import wizard). importCustomers(records) { state.customers = state.customers || {}; let added = 0; const noNumber = []; const CNx = window.CustNum; (records || []).forEach((c, i) => { const given = String(c.custNum || "").trim(); const custNum = CNx ? (CNx.numberOfRecord(c) || (CNx.isCustNum(given) ? given : "")) : (given || (c.phone || "").replace(/\D/g, "").slice(-7)); if (!custNum) { noNumber.push(i + 1); return; } const existing = state.customers[custNum] || {}; const next = { ...existing, ...c, custNum, saved: _iso(new Date()) }; if (CNx) { for (const k of CNx.SERVER_FIELDS) { if (existing[k] !== undefined) next[k] = existing[k]; else if (k !== "names") delete next[k]; } next.names = CNx.namesOf(next); } state.customers[custNum] = next; added++; }); if (!added) return { ok: false, msg: noNumber.length ? `No customer could be numbered: ${noNumber.length} row(s) have no phone — use the Import wizard (it asks K2B for a number).` : "No customer records found to import." }; logAudit("Customer Import", `${added} record(s) from document${noNumber.length ? "; " + noNumber.length + " without a phone not imported" : ""}`); persist(); notify(); return { ok: true, msg: `Imported ${added} customer(s).${noNumber.length ? " " + noNumber.length + " row(s) without a phone were not imported (the Import wizard numbers those)." : ""}`, counts: { customers: added, noNumber: noNumber.length } }; }, // Import classified datasets from the document importer. Each entry is // { collection, rows }. GL accounts route to the Accounting COA store so // they appear in the Accounting screen; everything else lands in the ledger. importDatasets(datasets, opts = {}) { // mode: "merge" (default — de-dupe & append) or "replace" (clear each // target collection first, then load the file as the new source of truth). const mode = (opts && opts.mode) === "replace" ? "replace" : "merge"; const counts = {}; const cleared = new Set(); const clearOnce = (key, fn) => { if (mode === "replace" && !cleared.has(key)) { fn(); cleared.add(key); } }; // Trial balances, aged schedules and deal recaps need the preview-and-confirm Import wizard (they post // opening balances / sub-ledger rows) — this quick loader must never guess at them. const WIZARD = { glTrial: "General Ledger › Trial balance", schedule: "General Ledger › Aged schedule", dealRecap: "Deals" }; const wiz = (datasets || []).filter(ds => WIZARD[ds.collection]); if (wiz.length && wiz.length === (datasets || []).length) return { ok: false, msg: `This file looks like ${wiz.map(ds => ds.label).join(", ")}. Load it with the Import wizard (destination: ${[...new Set(wiz.map(ds => WIZARD[ds.collection]))].join(" / ")}) so you can preview it first — nothing was imported.` }; (datasets || []).forEach(ds => { const rows = ds.rows || []; if (!rows.length) return; if (WIZARD[ds.collection]) return; if (ds.collection === "inventory") { const r = api.importInventory(rows, { clearBlanks: !!opts.clearBlanks, replace: mode === "replace" }); counts.inventory = (counts.inventory || 0) + r.created + r.updated; api.__lastInventoryImport = r; return; } if (ds.collection === "customers") { clearOnce("customers", () => { state.customers = {}; }); state.customers = state.customers || {}; // RC30 (cn): numbered by the rule (the phone without the area code); a row with neither a phone nor a 7-digit number is not loaded (never a TMP key) let skipped = 0; rows.forEach((c) => { const CNx = window.CustNum; const given = String(c.custNum || "").trim(); const cn = CNx ? (CNx.numberOfRecord(c) || (CNx.isCustNum(given) ? given : "")) : (given || (c.phone || "").replace(/\D/g, "").slice(-7)); if (!cn) { skipped++; return; } const ex = state.customers[cn] || {}; const nx = { ...ex, ...c, custNum: cn, saved: _iso(new Date()) }; if (CNx) { for (const k of CNx.SERVER_FIELDS) { if (ex[k] !== undefined) nx[k] = ex[k]; else if (k !== "names") delete nx[k]; } nx.names = CNx.namesOf(nx); } state.customers[cn] = nx; }); counts.customers = (counts.customers || 0) + rows.length - skipped; if (skipped) counts.customersNoPhone = (counts.customersNoPhone || 0) + skipped; } else if (ds.collection === "glAccounts") { // Merge into the Accounting working chart of accounts (its own store). try { const KEY = "jbrews-acct-coa-v2"; const cur = mode === "replace" ? [] : JSON.parse(localStorage.getItem(KEY) || "[]"); const byAcct = {}; cur.forEach(a => byAcct[a.acct] = a); rows.forEach(r => { if (r.acct) byAcct[r.acct] = { acct: r.acct, name: r.name || (byAcct[r.acct] && byAcct[r.acct].name) || "", type: r.type || (byAcct[r.acct] && byAcct[r.acct].type) || "Asset", bal: r.bal || 0, sched: (byAcct[r.acct] && byAcct[r.acct].sched) || false }; }); localStorage.setItem(KEY, JSON.stringify(Object.values(byAcct))); } catch (e) {} counts.glAccounts = (counts.glAccounts || 0) + rows.length; } else if (Array.isArray(state[ds.collection])) { // Ledger collections. Replace empties in place so live refs stay valid. clearOnce(ds.collection, () => { state[ds.collection].length = 0; }); const coll = state[ds.collection]; const idOf = (r) => r && (r.id || r.acct || r.stock || r.part || r.vin || r.invoice); const seen = new Set(coll.map(idOf).filter(Boolean)); rows.forEach(r => { const k = idOf(r); if (!k || !seen.has(k)) { coll.push(r); if (k) seen.add(k); } }); counts[ds.collection] = (counts[ds.collection] || 0) + rows.length; } }); const total = Object.values(counts).reduce((s, v) => s + v, 0); if (!total) return { ok: false, msg: "Nothing recognized to import." }; logAudit("Data Import", (mode === "replace" ? "[REPLACE] " : "[MERGE] ") + Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(", ")); persist(); notify(); return { ok: true, msg: `${mode === "replace" ? "Replaced with" : "Imported"} ${total.toLocaleString()} record(s) across ${Object.keys(counts).length} dataset(s).`, counts }; }, // Generic setter for A/R setup tables (account types, tax codes, dunning, letters) arSetTable(key, value) { if (!["arAccountTypes", "arTaxCodes", "arDunningMessages", "arLetterTemplates"].includes(key)) return { ok: false, msg: "Unknown A/R table." }; state[key] = value; persist(); notify(); return { ok: true, msg: "Setup file saved." }; }, // Deposits = posted payments grouped by day (for the Deposit Report) arDeposits() { const out = {}; (state.arCustomers || []).forEach(c => { (c.txns || []).filter(t => t.credit > 0 && t.kind === "PAY").forEach(t => { const day = _fmtDate(t.date); out[day] = out[day] || { day, items: [], total: 0 }; out[day].items.push({ acct: c.acct, name: c.name, ref: t.ref, desc: t.desc, amount: t.credit }); out[day].total += t.credit; }); }); return Object.values(out).sort((a, b) => new Date(b.items[0] ? 0 : 0) - 0) .sort((a, b) => new Date(b.day) - new Date(a.day)); }, // ── KPI snapshot for dashboard headline ── kpis() { const s = (state && !state.__locked && Array.isArray(state.deals)) ? state : _blank(); // same guard as exceptions(): an unloaded state = zeros, not a crash // H13 (sales features): number guards — a Sales login receives other people's deals and the // inventory without amounts, so a missing field must count as 0, never turn a tile into "$NaN". const citTotal = s.deals.filter(d => d.postedToGL && d.status !== "Funded").reduce((sum, d) => sum + (+d.amtFinanced || 0), 0); const invValue = s.inventory.filter(u => u.status === "In Stock").reduce((sum, u) => sum + (+u.cost || 0) + (+u.recon || 0), 0); const floorTotal = s.floorplanPayoffsDue.filter(f => !f.paid).reduce((sum, f) => sum + (+f.amount || 0), 0); // Deals imported from the old DMS are history (their money is in the opening balances) — they never count as this store's units / gross. const monthUnits = s.deals.filter(d => d.postedToGL && !d.imported).length; // RC28 (fx, gate 2b): the gross of a posted deal is the desk's / the posting's (DMSCore.dealGross — cost lines, pack, over-allowance) const monthGross = s.deals.filter(d => d.postedToGL && !d.imported).reduce((sum, d) => { const g = api.dealGross ? api.dealGross(d) : null; return sum + (g ? g.totalGrossCents / 100 : ((+d.salePrice || 0) - (+d.cost || 0)) + (+d.fiGross || 0)); }, 0); const warrantyOpen = s.warrantyReceivables.filter(w => !w.paid && w.status !== "Debited" && !w.voided).reduce((sum, w) => sum + wOpenOf(w), 0); const inStock = s.inventory.filter(u => u.status === "In Stock").length; return { citTotal, invValue, floorTotal, monthUnits, monthGross, warrantyOpen, inStock }; }, }; // ── 5-minute safety-net auto-sync ───────────────────────────────────── // Belt-and-braces timer: online → flush anything pending; offline or // auth-expired WITH a live (unexpired) token → try to reconnect and, on // success, force-push everything once. Expired token → do nothing (getting // a new session is a human action; the banner stays visible). const SYNC_INTERVAL_MS = 5 * 60 * 1000; let autoSyncBusy = false; // in-flight guard — overlapping ticks can't double-run async function autoSyncTick() { if (autoSyncBusy) return; autoSyncBusy = true; try { if (__offlineByChoice) return; // explicit offline session — never auto-sync if (__connState === "online") { // Flush any pending dirty records right now (no-op when clean). if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } await flushNow(); await refreshRoster(); // keep the team roster fresh return; } if (__connState !== "offline" && __connState !== "auth-expired") return; // "connecting" — leave it alone if (__offlineByChoice) return; // user chose a local session — only a real sign-in ends it const ti = decodeTokenInfo(); if (!AUTH_TOKEN || !ti || ti.expired) return; // no usable session — human must sign in const r = await loadFromServer(); // fires k2b-conn on any transition if (r && r.ok) { connectSocket(); await api.pushAll(); // one full recovery push await refreshRoster(); // roster too, now that we're back notify(); // UI banner flips green } } catch (e) { /* next tick retries; state transitions already announced loudly */ } finally { autoSyncBusy = false; } } if (typeof window !== "undefined") { window.DMS = api; // (The fabricated lender-decision simulator that used to tick here every // 4 s is gone — A-11. Submissions stay "SUBMITTED" / pending until a real // lender integration posts a decision via DMS.recordLenderDecision.) // Start the safety-net sync exactly once, even if DMSCore re-evaluates // (script re-injection / re-boot): the interval id lives on window. if (!window.__k2bAutoSyncTimer) { window.__k2bAutoSyncTimer = setInterval(() => { autoSyncTick(); }, SYNC_INTERVAL_MS); } } return api; })(); if (typeof window !== "undefined") { window.DMS = DMS; window.DMS_ACCT = ACCT; // ── ONE PATH helpers shared by every screen ─────────────────────────── // jbPostDeal(dealId): runs DMS.dealCloseGates, shows ONE confirm listing // every soft block (logged as an override), refuses on hard blocks, then // posts to accounting. jbFundDeal(dealId): marks funded → DONE. // RC21 (deals): posting / funding are SERVER commands — both return a promise (await it); the toast says what the server answered. window.jbPostDeal = async (dealId, opts) => { const o = opts || {}; const toast = (m, t, ms) => window.k2bToast && window.k2bToast(m, t || "ok", ms || 4200); if (DMS.can && !DMS.can("glEdit")) { const r0 = await DMS.postDeal(dealId); toast(r0.msg, "warn", 7000); return r0; } // (ldg round 2) the office posts deals — say so before the close-gate questions const g = DMS.dealCloseGates(dealId); const hard = g.blocks.filter(b => b.hard), soft = g.blocks.filter(b => !b.hard); if (hard.length) { toast("⛔ Can't post " + dealId + ": " + hard.map(b => b.label).join(" · "), "warn", 6000); return { ok: false, msg: hard[0].label, blocks: g.blocks }; } if (soft.length && !o.force) { const list = soft.map(b => "• " + b.label + (b.detail ? " — " + b.detail : "")).join("\n"); if (!window.confirm(`⚠ ${dealId} — before posting to accounting:\n\n${list}\n\nPost anyway? (override is logged)`)) { toast("Post cancelled — fix the items above first.", "warn", 3800); return { ok: false, msg: "cancelled", blocks: g.blocks }; } try { DMS.logAudit && DMS.logAudit("Post Override", dealId + " — " + soft.map(b => b.code).join(", ")); } catch (e) {} } const r = await DMS.postDeal(dealId, o); const warn = r.ok && r.warnings && r.warnings.length; toast(r.ok ? r.msg + " → Accounting" : "⛔ " + r.msg, r.ok ? (warn ? "warn" : "ok") : "warn", warn ? 7000 : (r.ok ? 4500 : 7000)); return r; }; window.jbFundDeal = async (dealId) => { const r = await DMS.fundDeal(dealId); window.k2bToast && window.k2bToast(r.msg, r.ok ? "ok" : "warn", 4500); return r; }; }