/* 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 (