`;
}).join("");
return crHead("F&I TO GL INTERFACE — OLD DMS vs K2B") + `
Deal item
Old acct
Old name
K2B acct
K2B name
Status
${rows}
Old values are from the old DMS setup screen (control by Stock). K2B accounts come from Settings › GL Flow — change them there. Sale, cost and inventory accounts follow each vehicle's own accounts (see Chart of Accounts).
`;
};
/* ═══ 5. CHART OF ACCOUNTS ════════════════════════════════════════════════ */
// Filter: every word must match (AND) — an account number PREFIX, or a word / fragment of the name, alter number or type.
const crCoaHtml = (filter) => {
const m = crCOA();
const toks = crParseTokens(filter);
const all = Object.values(m);
const rows = all.filter(a => {
if (!toks.length) return true;
const acct = String(a.acct).toLowerCase();
const hay = crHay([a.name, a.alt, a.type, a.cost, a.inv], []);
return toks.every(t => acct.startsWith(t.s) || acct.startsWith(t.h) || crHasTok(hay, t));
}).sort((a, b) => a.acct.localeCompare(b.acct, undefined, { numeric: true }));
const anyDerived = rows.some(a => a.dc && a.dcDerived);
return crHead("CHART OF ACCOUNTS", toks.length ? `filtered: ${rows.length} of ${all.length}` : "") + `
ACCT NUMBER
ALTER NUMBER
DESCRIPTION
TYPE
D/C flag (as printed)
COST
INVENT
${rows.map(a => `
${crEsc(a.acct)}
${crEsc(a.alt || "")}
${crEsc(String(a.name || "").toUpperCase())}
${crEsc(a.type || "")}
${crEsc(a.dc || "")}
${crEsc(a.cost || "")}
${crEsc(a.inv || "")}
`).join("")}
${rows.length}${toks.length ? " of " + all.length : ""} accounts. TYPE: A asset · L liability · S sale · C cost · E expense · I income. D/C flag (as printed) = the Y / N flag in the old chart's D/C column (a few print as "Y 50"); K2B shows it exactly as printed and does not treat it as a debit or credit side. ${anyDerived ? "Grey entries were not read from the old print (no D/C flag on file for that account) - they are only a K2B estimate. " : ""}COST / INVENT = the cost-of-sale and inventory accounts a sale account relieves.
`;
};
/* ═══ 6. AGED SCHEDULES (Schedule 0) ═════════════════════════════════════ */
const crScheduleData = (acct, asOf) => {
const by = {};
crJEs().forEach(j => {
const dt = crDate(j.date); if (asOf && dt && dt > asOf) return;
(j.lines || []).forEach(l => {
if (String(l.acct) !== String(acct)) return;
const c = crControl(j, l) || "(NO CONTROL)";
const o = by[c] || (by[c] = { control: c, desc: "", bal: 0, first: null });
o.bal += crNum(l.dr) - crNum(l.cr);
if (!o.desc) o.desc = (l.desc || j.desc || "").toUpperCase();
if (dt && (!o.first || dt < o.first)) o.first = dt;
});
});
const rows = Object.values(by).filter(o => Math.abs(o.bal) > 0.004).map(o => ({ ...o, bal: crR2(o.bal), days: crDays(o.first, asOf) }))
.sort((a, b) => a.control.localeCompare(b.control, undefined, { numeric: true }));
const over = rows.filter(r => r.days > 30);
return { rows, total: crR2(rows.reduce((s, r) => s + r.bal, 0)), count: rows.length, over30: crR2(over.reduce((s, r) => s + r.bal, 0)), over30n: over.length };
};
// Rows that match a filter box (control # / name / description / amount / age) — every word must match.
const crSchedFilter = (rows, filter) => {
const toks = crParseTokens(filter);
if (!toks.length) return rows;
return rows.filter(r => { const hay = crHay([r.control, r.desc], [...crAmtKeys(r.bal), r.days + "days", r.days + " days"]); return toks.every(t => crHasTok(hay, t)); });
};
const crSchedTotals = (rows) => { const over = rows.filter(r => r.days > 30); return { total: crR2(rows.reduce((s, r) => s + r.bal, 0)), count: rows.length, over30: crR2(over.reduce((s, r) => s + r.bal, 0)), over30n: over.length }; };
// Sub-ledger detail brought in by Upload tool ▸ Aged schedule (never posted to the GL): 261 → factory receivable rows, 220 / 220A → A/R transactions,
// each flagged imported + schedule + asOf. Days are the days PRINTED on the old schedule (as of its print date).
const crImpSched = (acct) => {
const D = crD(), a = String(acct), rows = []; let asOfKey = "";
try {
(D.factoryReceivables ? D.factoryReceivables() : []).forEach(f => { if (f && f.imported && String(f.schedule || f.glAcct) === a) { rows.push({ control: String(f.deal || ""), desc: /^Schedule \S+$/.test(String(f.program || "")) ? "" : String(f.program || "").toUpperCase(), bal: crNum(f.claimed != null ? f.claimed : f.amount), days: crNum(f.ageAsOf != null ? f.ageAsOf : f.age) }); asOfKey = asOfKey || String(f.asOfKey || ""); } });
(D.arCustomers ? D.arCustomers() : []).forEach(c => (c.txns || []).forEach(t => { if (t && t.imported && String(t.schedule) === a) { rows.push({ control: String(c.acct || ""), desc: String(c.name || "").toUpperCase(), bal: crNum(t.charge) - crNum(t.credit), days: crNum(t.days) }); asOfKey = asOfKey || String(t.asOfKey || ""); } }));
} catch (e) {}
rows.sort((x, y) => x.control.localeCompare(y.control, undefined, { numeric: true }) || x.bal - y.bal);
const m = /^(\d{4})(\d{2})(\d{2})$/.exec(asOfKey);
return { rows, asOf: m ? new Date(+m[1], +m[2] - 1, +m[3]) : null };
};
// The imported schedule printed under the GL-built one, with its own totals and a reconciliation line against the GL balance.
const crImpSchedHtml = (acct, filter) => {
const imp = crImpSched(acct); if (!imp.rows.length) return "";
const toks = crParseTokens(filter);
const shown = toks.length ? crSchedFilter(imp.rows, filter) : imp.rows;
const f = crSchedTotals(shown), all = crSchedTotals(imp.rows);
const glThen = imp.asOf ? crScheduleData(acct, imp.asOf).total : null, glNow = crScheduleData(acct, null).total;
const lbl = toks.length ? " (filtered)" : "";
const tail = `
FINAL TOTAL${lbl}
${crAmt(f.total)}
COUNT TOTAL${lbl}
${f.count}
OVER 30 $${lbl}
${crAmt(f.over30)}
OVER 30 CNT${lbl}
${f.over30n}
`
+ (toks.length ? `
ALL FINAL TOTAL (unfiltered)
${crAmt(all.total)}
ALL COUNT TOTAL (unfiltered)
${all.count}
` : "")
+ `
GL ${crEsc(acct)} BALANCE${imp.asOf ? " AS OF " + crMDY(imp.asOf) : ""}
This is the old DMS's own schedule (sub-ledger detail from Upload tool ▸ Aged schedule), as printed - it is NOT posted to the general ledger and does not change the schedule above. A difference to the GL is normal when the trial balance was posted at a different date than this schedule was printed.
FILTERED SCHEDULE - showing ${shown.length} of ${s.rows.length} items. The filtered totals below are NOT the schedule total; the full schedule total is the "ALL" block.
Built from the general ledger: each control (stock #, RO, deal or reference) with a balance in ${crEsc(acct)}; age = days since its first posting. Opening balances from an imported trial balance have no control # and show as one line (OB-...)${crImpSched(acct).rows.length ? " - the old DMS's own detail behind it is printed below." : "."}
Live from the K2B ledger. Units = unsold vehicles in each inventory account; retail units = deals posted that month. ${Math.abs(diff) > 0.01 ? `Assets differ from liabilities + net worth by ${crAmt(diff)} — an account outside the 2xx/3xx/P&L ranges has a balance.` : "Assets = liabilities + net worth ✓"}
`;
};
/* ═══ 10. VEHICLE MASTER REPORT ═══════════════════════════════════════════ */
const crVehOnReport = (u) => u && window.DealCalc.unitOnLot(u); // RC28 fix round: the ONE on-lot reading (lib/deal-calc.js)
// A unit's balance in its own inventory account, from the ledger. Same rule as DMS.unitGL (an entry belongs to the unit when its
// stock = the unit, its deal is a deal of the unit, or its text mentions the stock # / VIN) but it only looks at entries that touch the
// inventory account and compiles one RegExp per unit - DMS.unitGL scans every journal line and builds a RegExp for each of them,
// which takes seconds per report once the journal holds ~10,000 entries.
const crGlCtx = () => {
const byAcct = new Map(), dealsByStock = new Map();
crJEs().forEach(j => { if (!j) return; const seen = new Set(); (j.lines || []).forEach(l => { const a = l && l.acct; if (a == null || seen.has(a)) return; seen.add(a); let arr = byAcct.get(a); if (!arr) byAcct.set(a, arr = []); arr.push(j); }); });
crDeals().forEach(d => { if (!d) return; const st = String(d.stock || "").toUpperCase(); if (!st) return; let set = dealsByStock.get(st); if (!set) dealsByStock.set(st, set = new Set()); set.add(d.id); });
return { byAcct, dealsByStock };
};
const crUnitInvGL = (u, ctx) => {
const A = crD()._ACCT || {}, isNew = /^n/i.test(String(u.type || ""));
const inv = u.invAcct || (isNew ? A.invNewCar : A.invUsedCar);
const stock = String(u.stock).toUpperCase(), vin = String(u.vin || "").toUpperCase();
const dealIds = ctx.dealsByStock.get(stock) || null;
const re = stock ? new RegExp("\\b" + stock.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&") + "\\b") : null;
const mentions = (t) => { const x = String(t || "").toUpperCase(); return !!((re && re.test(x)) || (vin && x.includes(vin))); };
let bal = 0, booked = false;
(ctx.byAcct.get(inv) || []).forEach(j => {
const mine = String(j.stock || "").toUpperCase() === stock || !!(j.dealId && dealIds && dealIds.has(j.dealId)) || mentions(j.desc) || (j.lines || []).some(l => mentions(l.desc));
if (!mine) return;
booked = true; bal += (j.lines || []).filter(l => l.acct === inv).reduce((x, l) => x + (+l.dr || 0) - (+l.cr || 0), 0);
});
return { booked, bal: +bal.toFixed(2) };
};
const crVehRows = (units) => { const ctx = crGlCtx(); return (units || crInv().filter(crVehOnReport)).map(u => {
const basis = crD().costBasis ? crD().costBasis(u.stock) : { found: false };
const purchase = crNum(u.invoiceCost || u.cost);
const repairs = crR2((basis.found ? basis.reconTotal : 0) + crNum(u.recon));
const total = crR2(purchase + repairs + crNum(u.pack));
let gl = null, glRef = false;
try { const g = crUnitInvGL(u, ctx); if (g.booked) gl = g.bal; } catch (e) {}
// an imported unit the ledger has no entry for (the trial balance holds the account total only): GL COST = the cost the old DMS's vehicle master printed (shown grey)
if (gl == null && u.imported && u.costBooked != null && u.costBooked !== "" && Number.isFinite(+u.costBooked)) { gl = crR2(u.costBooked); glRef = true; }
const isN =/^n/i.test(u.type || ""), isT = /truck/i.test((u.type || "") + " " + (u.subType || "") + " " + (u.body || ""));
return { u, inv: String(u.invAcct || (isN ? (isT ? "237" : "231") : (isT ? "241" : "240"))), year: u.yr, nu: /^n/i.test(u.type || "") ? "N" : "U", make: String(u.mk || "").toUpperCase().slice(0, 7),
model: String(u.mdl || "").toUpperCase().slice(0, 9), color: String(u.color || u.extColor || "").toUpperCase().slice(0, 6), stock: u.stock,
booked: gl != null && !glRef, glRef, invDate: u.invoiceDate || u.stockedDate, age: u.age != null && u.age !== "" ? crNum(u.age) : crDays(u.invoiceDate || u.stockedDate),
retail: crNum(u.asking), purchase, repairs, total, gl: gl == null ? 0 : gl, vin: u.vin || "", miles: u.miles };
}); };
// filter = the filter box: stock, VIN fragment (full or last 4-8), year / make / model, color, inventory account, N / U (or new / used).
// Rows, subtotals, Total Cars and Averages are recomputed on the filtered set and the heading says "filtered: x of y".
const crVehHtml = (sort, filter) => {
let units = crInv().filter(crVehOnReport);
const allCount = units.length;
const ftoks = crParseTokens(filter, { nu: true });
if (ftoks.length) { const keep = new Set(crSearch(crGetIndex("vehicle"), filter).list.map(it => String(it.key))); units = units.filter(u => keep.has(String(u.stock))); }
const rows = crVehRows(units);
const key = sort === "vin" ? (r) => r.vin : sort === "date" ? (r) => String(crMDY(r.invDate)).slice(6) + crMDY(r.invDate) : (r) => r.inv + "|" + r.stock;
rows.sort((a, b) => String(key(a)).localeCompare(String(key(b)), undefined, { numeric: true }));
const sumOf = (rs, k) => crR2(rs.reduce((s, r) => s + crNum(r[k]), 0));
const line = (r) => `
FILTERED REPORT - ${fl}. These are NOT all the vehicles: the subtotals, Total Cars and Averages below cover only the ${rows.length} vehicle${rows.length === 1 ? "" : "s"} shown.
` : "") + `
YEAR
N/U
MAKE
MODEL
COLOR
STOCK #
INVOICE DATE
AGE
RETAIL PRICE
PURCHASE COST
TOTAL REPAIRS
TOTAL COST
GL COST
VIN
MILES
${body}
Total Cars ${rows.length}
${Math.round(sumOf(rows, "age") / n)}
${Math.round(sumOf(rows, "retail"))}
${crAmt(sumOf(rows, "purchase"))}
${crAmt(sumOf(rows, "repairs"))}
${crAmt(sumOf(rows, "total"))}
${crAmt(sumOf(rows, "gl"))}
Averages
${Math.round(sumOf(rows, "retail") / n)}
${crAmt(sumOf(rows, "purchase") / n)}
${crAmt(sumOf(rows, "repairs") / n)}
${crAmt(sumOf(rows, "total") / n)}
${crAmt(sumOf(rows, "gl") / n)}
TOTAL COST = purchase + repairs + pack. GL COST = the unit's balance in its inventory account (red when it differs from purchase + repairs — look at that unit's Accounting tab; "not booked" = no purchase entry in the ledger yet). Grey GL COST = a unit brought in by the vehicle-master import that the ledger has no entry for: it shows the cost the old DMS printed (the account total sits in the imported trial balance).
`;
};
/* ═══ SEARCH ENGINE ═══════════════════════════════════════════════════════ */
// One lowercase "haystack" per record, built once per data version (collection length + first / last id + sum of
// record revisions) and reused for every keystroke. Rules: several words = AND; case, punctuation and accents are
// ignored; partial words are fine ("lanc" finds LANCASTER); phones match on digits only (any punctuation);
// amounts match 23995 / 23,995.00 / $23,995; dates match 9/29, 09-29-26, 2026-09-29 and month words ("sept").
const CR_MON = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
const CR_MONF = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"];
const CR_PAGE = 50; // most result rows ever rendered at once
const CR_DEBOUNCE = 120; // ms between the last keystroke and the search
const CR_ANY_PER = 6; // rows per group in "Find anything"
const crP2 = (n) => String(n).padStart(2, "0");
const crFold = (s) => { let t = String(s == null ? "" : s); try { t = t.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); } catch (e) {} return t.toLowerCase(); };
// same length as the input (so highlight ranges map back onto the original text)
const crFoldAligned = (s) => { let out = ""; for (let i = 0; i < s.length; i++) { const c = s[i]; let f = c; try { f = c.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); } catch (e) {} f = f.toLowerCase(); out += f.length === 1 ? f : (c.toLowerCase().length === 1 ? c.toLowerCase() : c); } return out; };
const crIdKey = (v) => crFold(v).replace(/[^a-z0-9]/g, "");
const crDateKeys = (v) => {
const d = v ? crDate(v) : null; if (!d || isNaN(d.getTime())) return [];
const m = d.getMonth();
return ["d:" + d.getFullYear() + "-" + crP2(m + 1) + "-" + crP2(d.getDate()), "m:" + crP2(m + 1) + "-" + crP2(d.getDate()), CR_MON[m], CR_MONF[m], m === 8 ? "sept" : ""];
};
// canonical amount strings: 23995.00 and 23995 (+ unsigned forms of negatives) so 23995 / 23,995.00 / $23,995 all hit
const crAmtKeys = (n) => {
const v = crR2(n); if (!v) return [];
const out = [v.toFixed(2)];
if (v < 0) out.push((-v).toFixed(2));
if (Number.isInteger(v)) { out.push(String(v)); if (v < 0) out.push(String(-v)); }
return out;
};
// fields = free text (words / punctuation folded away); extras = canonical keys appended as-is (dates, amounts, digits)
const crHay = (fields, extras) => {
const f = fields.map(crFold).join(" ");
const ex = " " + (extras || []).filter(Boolean).join(" ") + " ";
return { h: " " + f.replace(/[^a-z0-9]+/g, " ") + ex, s: " " + f.replace(/[^a-z0-9 ]+/g, "") + ex };
};
const crValidMD = (m, d) => m >= 1 && m <= 12 && d >= 1 && d <= 31;
const crTok = (raw, o) => {
const f = crFold(raw).trim(); if (!f) return null;
if (o && o.nu) { if (f === "n" || f === "new") return { h: "nu_n", s: "nu_n", kind: "nu" }; if (f === "u" || f === "used") return { h: "nu_u", s: "nu_u", kind: "nu" }; }
let m;
if ((m = /^(\d{4})[\/\-.](\d{1,2})[\/\-.](\d{1,2})$/.exec(f)) && crValidMD(+m[2], +m[3])) { const k = "d:" + m[1] + "-" + crP2(m[2]) + "-" + crP2(m[3]); return { h: k, s: k, kind: "date", mo: +m[2], d: +m[3], y: m[1] }; }
if ((m = /^(\d{1,2})[\/\-.](\d{1,2})[\/\-.](\d{4}|\d{2})$/.exec(f)) && crValidMD(+m[1], +m[2])) { const y = m[3].length === 2 ? "20" + m[3] : m[3]; const k = "d:" + y + "-" + crP2(m[1]) + "-" + crP2(m[2]); return { h: k, s: k, kind: "date", mo: +m[1], d: +m[2], y }; }
if ((m = /^(\d{1,2})[\/\-.](\d{1,2})$/.exec(f)) && crValidMD(+m[1], +m[2])) { const k = "m:" + crP2(m[1]) + "-" + crP2(m[2]); return { h: k, s: k, kind: "date", mo: +m[1], d: +m[2] }; }
const n = f.replace(/^\$/, "");
if (/^-?(\d+|\d{1,3}(,\d{3})+)(\.\d+)?$/.test(n)) {
const c = n.replace(/,/g, ""), t = { h: c, s: c, kind: "num" };
// 555.0142 is a phone fragment as well as a decimal number: if the number reading finds nothing, try it as plain text
if (/[.,]/.test(f)) t.alt = { h: f.replace(/[^a-z0-9]+/g, " ").trim(), s: f.replace(/[^a-z0-9]+/g, ""), kind: "text" };
return t;
}
const sq = f.replace(/[^a-z0-9]+/g, ""); if (!sq) return null;
return { h: f.replace(/[^a-z0-9]+/g, " ").trim(), s: sq, kind: "text" };
};
const crParseTokens = (q, o) => String(q == null ? "" : q).trim().split(/\s+/).slice(0, 10).map(w => crTok(w, o)).filter(Boolean);
// regexes (run on the folded text) that find the typed words again in a result line, so they can be highlighted
const crHiRes = (toks) => toks.reduce((a, t) => a.concat(t.alt ? [t, t.alt] : [t]), []).map(t => {
try {
if (t.kind === "nu") return null;
if (t.kind === "date") { const md = "0?" + t.mo + "[\\-\\/.]0?" + t.d; return new RegExp(t.y ? md + "[\\-\\/.](?:" + String(t.y).slice(-2) + "|" + t.y + ")" : md, "g"); }
const digitsOnly = /^\d+$/.test(t.s);
if (t.kind === "num" && !digitsOnly) return new RegExp(t.h.split("").map(ch => ch.replace(/[.\-]/g, "\\$&")).join(",?"), "g");
return new RegExp(t.s.split("").join(digitsOnly ? "[^0-9a-z]{0,2}" : "['.\\-\\/]?"), "g");
} catch (e) { return null; }
}).filter(Boolean);
const crHi = (text, res) => {
const s = String(text == null ? "" : text);
if (!s || !res || !res.length) return s;
const f = crFoldAligned(s); const mark = new Uint8Array(s.length); let any = false;
res.forEach(re => { re.lastIndex = 0; let m, guard = 0; while ((m = re.exec(f)) && guard++ < 60) { if (!m[0]) { re.lastIndex++; continue; } for (let k = m.index; k < m.index + m[0].length; k++) mark[k] = 1; any = true; } });
if (!any) return s;
const out = []; let i = 0, key = 0;
while (i < s.length) { let j = i; const on = mark[i]; while (j < s.length && mark[j] === on) j++; const seg = s.slice(i, j); out.push(on ? {seg} : seg); i = j; }
return out;
};
// how well one word hits one haystack: whole word 40, start of a word 25, anywhere 10 (squashed forms a little lower); 0 = no hit
const crTokW = (h, s, t) => {
let w = 0;
if (h.indexOf(" " + t.h + " ") >= 0) w = 40; else if (h.indexOf(" " + t.h) >= 0) w = 25; else if (h.indexOf(t.h) >= 0) w = 10;
else if (s.indexOf(" " + t.s + " ") >= 0) w = 36; else if (s.indexOf(" " + t.s) >= 0) w = 22; else if (s.indexOf(t.s) >= 0) w = 8;
if (!w && t.alt) w = crTokW(h, s, t.alt);
return w;
};
const crHasTok = (hay, t) => crTokW(hay.h, hay.s, t) > 0;
// ranking helpers: exact / prefix id hits, VIN + phone tails, exact amounts and exact words (stage …) rank first
const crBonus = (it, t) => {
let b = 0; const s = t.s;
if (it.ids) for (let i = 0; i < it.ids.length; i++) { const id = it.ids[i]; if (id === s) { b += 100; break; } if (id.startsWith(s)) b = Math.max(b, 50); }
if (s.length >= 4 && it.tails) for (let i = 0; i < it.tails.length; i++) if (it.tails[i].endsWith(s)) { b += 60; break; }
if (it.amts && it.amts.indexOf(t.h) >= 0) b += 80;
if (it.words && it.words.indexOf(s) >= 0) b += 35;
return b;
};
// → { list: records in rank order (all of them), toks, hl }. Empty query → the base order (newest first / by number).
const crSearch = (idx, q) => {
const toks = crParseTokens(q, { nu: idx.kind === "vehicle" });
if (!toks.length) return { list: idx.items, toks, hl: [] };
const items = idx.items, nt = toks.length, out = [];
// RC31 (fx31, RC30 acceptance F6): a deal / a customer is found by an OLD 10-digit number or a phone in any format of its account (DMS.custQueryHit —
// the one reading every search box shares); the words keep their own scoring
const D0 = window.DMS; const byCust = (idx.kind === "deal" || idx.kind === "customer") && D0 && D0.custNumsFor && D0.custNumsFor(q).length ? (it) => { const r = it.ref || {}; return D0.custQueryHit(q, idx.kind === "customer" ? (r.custNum || it.key) : r.custNum, [r.phone, r.cell]); } : null;
for (let i = 0; i < items.length; i++) {
const it = items[i]; let sc = 0, ok = true;
for (let k = 0; k < nt; k++) {
const w = crTokW(it.h, it.s, toks[k]);
if (!w) { ok = false; break; }
sc += w + crBonus(it, toks[k]);
}
if (!ok && byCust && byCust(it)) { ok = true; sc = 150; }
if (ok) out.push({ it, sc });
}
out.sort((a, b) => b.sc - a.sc || a.it.ord - b.it.ord);
return { list: out.map(x => x.it), toks, hl: crHiRes(toks) };
};
/* ── indexes: one per kind, rebuilt only when the data version changes ── */
const crVerArr = (arr, idf, fn) => {
const a = arr || [], n = a.length; if (!n) return "0";
let rs = 0; for (let i = 0; i < n; i++) { const r = a[i]; if (r) rs += (+r.rev || 0) + (r.postedToGL ? 1 : 0) + (r.reversedBy ? 1 : 0) + (r.edits ? r.edits.length : 0) + (fn ? fn(r) : 0); }
return n + "|" + idf(a[0]) + "|" + idf(a[n - 1]) + "|" + rs;
};
const crVerCust = () => { const c = (crD().customers && crD().customers()) || {}; const ks = Object.keys(c); let h = 0; for (let i = 0; i < ks.length; i++) { const x = c[ks[i]] || {}; h = (h * 31 + (Date.parse(x.saved) % 100003 || 0) + String(x.lastName || "").length + String(x.phone || "").length) % 1000003; } return ks.length + "|" + ks[ks.length - 1] + "|" + h; };
const crGl = () => ((crD().glAccounts && crD().glAccounts()) || []);
const crInvVer = () => crVerArr(crInv(), u => u && u.stock, u => String(u.status || "").length + String(u.invAcct || "").length);
const crMkIndex = (kind, recs) => {
const items = recs.filter(Boolean), byKey = new Map(), byUp = new Map();
items.forEach((it, i) => { it.ord = i; byKey.set(String(it.key), it); byUp.set(String(it.key).toUpperCase(), it); });
return { kind, items, byKey, byUp };
};
const crDealCob = (d) => d.cobuyer || (d.creditApp && d.creditApp.coBuyer && [d.creditApp.coBuyer.first, d.creditApp.coBuyer.last].filter(Boolean).join(" ")) || "";
const crDealDate = (d) => d.delivered || d.date || d.created;
const crUsd0 = (n) => "$" + Math.round(crNum(n)).toLocaleString("en-US");
const crDealBuild = () => {
const units = new Map(); crInv().forEach(u => { if (u && u.stock) units.set(String(u.stock).toUpperCase(), u); });
const recs = crDeals().map((d, i) => {
if (!d || d.id == null) return null;
const u = units.get(String(d.stock || "").toUpperCase()) || null, uu = u || {};
const vin = String(d.vin || uu.vin || ""), phone = String(d.phone || ""), dig = phone.replace(/\D/g, "");
const dtv = crDealDate(d), dt = dtv ? crDate(dtv) : null;
const stage = String(d.stage || "").trim();
const words = [crIdKey(stage)]; if (d.postedToGL) words.push("posted"); if (d.imported) words.push("imported");
const hay = crHay([d.id, d.invoiceNo, d.invNo, d.buyer, crDealCob(d), d.stock, vin, d.vehicle, uu.yr, uu.mk, uu.mdl, uu.color, phone, d.salesperson, d.lender || "cash", (d.recap && d.recap.fiMgr) || d.fiManager,
stage, d.status, d.tradeDesc, d.tradeStock, d.tradeVin, d.custNum, d.street, d.city, d.state, d.postal, d.postedToGL ? "posted" : "", d.imported ? "imported" : ""],
[...crDateKeys(dtv), ...crAmtKeys(d.salePrice), ...crAmtKeys(d.amtFinanced), ...crAmtKeys(d.payment), dig.length >= 7 ? dig : ""]);
return { key: String(d.id), ref: d, u, h: hay.h, s: hay.s, ids: [crIdKey(d.id), crIdKey(String(d.id).replace(/^\D+/, "")), crIdKey(d.invoiceNo), crIdKey(d.invNo), crIdKey(d.stock)].filter(Boolean),
tails: [crFold(vin), dig].filter(Boolean), amts: crAmtKeys(d.salePrice), words, ts: dt ? dt.getTime() : 0, i };
}).filter(Boolean).sort((a, b) => b.ts - a.ts || a.i - b.i);
return crMkIndex("deal", recs);
};
const crDealView = (it) => {
const d = it.ref, u = it.u || {};
const cob = crDealCob(d), dtv = crDealDate(d);
const veh = [u.yr, u.mk, u.mdl || d.vehicle].filter(Boolean).join(" ");
return { title: `${d.id} · ${String(d.buyer || "—").toUpperCase()}${cob ? " & " + String(cob).toUpperCase() : ""}`,
sub: [d.stock, veh, d.vin || u.vin, d.phone, d.salesperson && String(d.salesperson).toUpperCase(), d.lender && String(d.lender).toUpperCase(), dtv && crDate(dtv) ? crMDY(dtv) : "", crNum(d.salePrice) ? crUsd0(d.salePrice) : ""].filter(Boolean).join(" · "),
tag: d.stage || (d.postedToGL ? "Posted" : "") };
};
const crJeBuild = () => {
const recs = crJEs().map((j, i) => {
if (!j || j.id == null) return null;
const lines = j.lines || [], accts = [], seen = new Set(), amts = new Set(), ctls = new Set(), descs = new Set();
lines.forEach(l => {
const a = String(l.acct == null ? "" : l.acct).trim(); if (a && !seen.has(a)) { seen.add(a); accts.push(a); }
crAmtKeys(crNum(l.dr) - crNum(l.cr)).forEach(k => amts.add(k));
const c = crControl(j, l); if (c) ctls.add(c);
if (l.desc && descs.size < 6) descs.add(l.desc);
});
const dt = j.date ? crDate(j.date) : null;
const hay = crHay([j.id, j.ref, j.dealId, "journal " + (j.jrn || ""), "jrn " + (j.jrn || ""), j.desc, j.stock, j.ro, j.src, crPeriod(j), accts.join(" "), Array.from(ctls).join(" "), Array.from(descs).join(" "), j.reversedBy ? "reversed" : "", j.corrects ? "corrected" : ""],
[...crDateKeys(j.date), ...Array.from(amts)]);
return { key: String(j.id), ref: j, accts, h: hay.h, s: hay.s, ids: [crIdKey(j.id), crIdKey(j.ref), crIdKey(j.dealId), crIdKey(String(j.dealId || "").replace(/^\D+/, "")), crIdKey(j.stock)].filter(Boolean),
accts2: accts.map(crIdKey), words: [String(j.jrn || ""), crIdKey(j.src)].filter(Boolean), amts: Array.from(amts), ts: dt ? dt.getTime() : 0, i };
}).filter(Boolean);
recs.forEach(r => { r.ids = r.ids.concat(r.accts2); }); // a bare account number typed = exact hit on any entry that uses it
recs.sort((a, b) => b.ts - a.ts || a.i - b.i);
return crMkIndex("journal", recs);
};
const crJeView = (it) => {
const j = it.ref, lines = j.lines || [];
return { title: `${j.id} · ${crMDY(j.date)} · ${String(j.desc || "").toUpperCase().slice(0, 56)}`,
sub: `doc ${j.ref || j.dealId || j.id} · jrn ${j.jrn || "-"}${j.stock ? " · " + j.stock : ""} · ${lines.length} lines · accts ${it.accts.join(" ")}`,
tag: crAmt(lines.reduce((s, l) => s + crNum(l.dr), 0)) };
};
const CR_TYPE_WORD = { A: "asset", L: "liability", S: "sale", C: "cost", E: "expense", I: "income" };
const crAcctBuild = () => {
const coa = crCOA(), bal = {};
crJEs().forEach(j => (j.lines || []).forEach(l => { const a = String(l.acct == null ? "" : l.acct).trim(); if (a) bal[a] = (bal[a] || 0) + crNum(l.dr) - crNum(l.cr); }));
Object.keys(bal).forEach(a => { if (!coa[a]) coa[a] = { acct: a, name: crAcctName(a), type: "" }; });
const recs = Object.values(coa).sort((a, b) => String(a.acct).localeCompare(String(b.acct), undefined, { numeric: true })).map(a => {
const hay = crHay([a.acct, a.name, a.alt, a.type, CR_TYPE_WORD[a.type] || "", a.cost, a.inv, a.group], []);
return { key: String(a.acct), ref: a, h: hay.h, s: hay.s, ids: [crIdKey(a.acct), crIdKey(a.alt)].filter(Boolean), bal: crR2(bal[a.acct] || 0) };
});
return crMkIndex("acct", recs);
};
const crAcctView = (it) => {
const a = it.ref;
return { pill: a.type || "", title: `${a.acct} ${String(a.name || "").toUpperCase()}`,
sub: [CR_TYPE_WORD[a.type] || a.type, a.alt && "alter " + a.alt, a.cost && "cost " + a.cost, a.inv && "inventory " + a.inv].filter(Boolean).join(" · "), tag: crAmt(it.bal) };
};
const crVehBuild = () => {
const recs = crInv().map((u, i) => {
if (!u || !u.stock) return null;
const isN = /^n/i.test(u.type || "");
const hay = crHay([u.stock, u.vin, u.yr, u.mk, u.mdl, u.color || u.extColor, u.invAcct, u.type, u.subType, u.body, u.status, isN ? "new" : "used"], ["nu_" + (isN ? "n" : "u")]);
return { key: String(u.stock), ref: u, h: hay.h, s: hay.s, ids: [crIdKey(u.stock)], tails: [crFold(u.vin || "")].filter(Boolean), i };
}).filter(Boolean);
return crMkIndex("vehicle", recs);
};
const crVehView = (it) => {
const u = it.ref;
return { title: `${u.stock} · ${[u.yr, u.mk, u.mdl].filter(Boolean).join(" ")}`,
sub: [u.vin, u.color || u.extColor, u.invAcct && "inv acct " + u.invAcct, u.status].filter(Boolean).join(" · "), tag: /^n/i.test(u.type || "") ? "N" : "U" };
};
const crCustBuild = () => {
const c = (crD().customers && crD().customers()) || {};
const recs = Object.keys(c).map((k, i) => {
const x = c[k]; if (!x) return null;
const num = String(x.custNum || k), dig = String(x.phone || x.cell || "").replace(/\D/g, ""); // RC30 (cn): the phone is the phone; the number is the number
const aliases = Array.isArray(x.aliases) ? x.aliases.map(String) : [];
const hh = Array.isArray(x.names) ? x.names.map((n) => n && [n.last, n.first].filter(Boolean).join(" ")) : [];
const hay = crHay([num, x.lastName, x.firstName, x.middleName, [x.lastName, x.firstName].filter(Boolean).join(" "), x.phone, x.street, x.street2, x.city, x.state, x.postal, x.email].concat(aliases, hh), [dig.length >= 7 ? dig : ""]);
return { key: num, ref: x, h: hay.h, s: hay.s, ids: [crIdKey(num)].concat(aliases.map(crIdKey)), tails: dig ? [dig] : [], i };
}).filter(Boolean).sort((a, b) => String(a.ref.lastName || "").localeCompare(String(b.ref.lastName || "")) || a.i - b.i);
return crMkIndex("customer", recs);
};
const crCustView = (it) => {
const c = it.ref;
return { title: [c.lastName, c.firstName].filter(Boolean).join(", ") || "(unnamed)",
sub: ["Customer # " + c.custNum, c.phone || c.cell, [c.street, c.city, c.state, c.postal].filter(Boolean).join(" "), c.email].filter(Boolean).join(" · "), tag: "" };
};
const CR_KINDS = {
deal: { label: "Deals", noun: "deals", ver: () => crVerArr(crDeals(), d => d && d.id) + "#" + crInvVer(), build: crDealBuild, view: crDealView },
journal: { label: "Journal documents", noun: "journal documents", ver: () => crVerArr(crJEs(), j => j && j.id), build: crJeBuild, view: crJeView },
acct: { label: "Accounts", noun: "accounts", ver: () => crVerArr(crJEs(), j => j && j.id) + "#" + crVerArr(crGl(), a => a && a.acct) + "#" + crInvVer(), build: crAcctBuild, view: crAcctView },
vehicle: { label: "Vehicles", noun: "vehicles", ver: crInvVer, build: crVehBuild, view: crVehView },
customer: { label: "Customers", noun: "customers", ver: crVerCust, build: crCustBuild, view: crCustView },
};
const CR_ANY_ORDER = ["deal", "journal", "acct", "vehicle", "customer"];
const CR_IDX = {};
const crGetIndex = (kind) => {
const cfg = CR_KINDS[kind], ver = cfg.ver(), c = CR_IDX[kind];
if (c && c.ver === ver) return c.idx;
const idx = cfg.build(); CR_IDX[kind] = { ver, idx }; return idx;
};
/* ── last 8 picks per signed-in user (localStorage is only a convenience: always try/catch) ── */
const crRecKey = (kind) => { let u = ""; try { u = (crD().userId && crD().userId()) || ""; } catch (e) {} return `k2b-cr-recent:${u || "anon"}:${kind}`; };
const crRecentGet = (kind) => { try { const a = JSON.parse(localStorage.getItem(crRecKey(kind)) || "[]"); return Array.isArray(a) ? a.slice(0, 8) : []; } catch (e) { return []; } };
const crRecentPush = (kind, key) => { try { const a = crRecentGet(kind).filter(x => x !== String(key)); a.unshift(String(key)); localStorage.setItem(crRecKey(kind), JSON.stringify(a.slice(0, 8))); } catch (e) {} };
/* ═══ 11. TRIAL BALANCE ═══════════════════════════════════════════════════ */
// Live from the K2B journal, laid out like the old DMS print: ACCOUNT NUMBER · MONTH TO DATE (COUNT, AMOUNT) ·
// YEAR TO DATE (COUNT, AMOUNT) · DESCRIPTION, an "Account Total" under every 3-character account family with more than
// one account (300 + 300D → 300), a "Group 300-399 Total" after each hundred-group and a final Total that must read 0.00.
// + debit / − credit (same as the old print).
// MONTH TO DATE = activity posted in the selected month (up to the "as of" day).
// YEAR TO DATE = P&L accounts (0xxx and 400 and up): activity since January 1 of the selected year (fiscal year = calendar year);
// balance-sheet accounts: the cumulative balance through the selected month.
// Entries whose src is "Opening balances" (the imported old-DMS trial balance) count toward YEAR TO DATE / balance but NOT toward MONTH TO DATE.
const crIsPL = (a) => /^0/.test(a) || +String(a).replace(/\D/g, "") >= 400;
const crIsOpeningJE = (j) => /^opening balances?$/i.test(String((j && j.src) || "").trim());
const crMonthEnd = (month) => new Date(+month.slice(0, 4), +month.slice(5, 7), 0);
const crISO = (d) => `${d.getFullYear()}-${crP2(d.getMonth() + 1)}-${crP2(d.getDate())}`;
const crTbMonths = () => {
const set = new Set();
crJEs().forEach(j => { const p = crPeriod(j); if (/^\d{4}-\d{2}$/.test(p)) set.add(p); });
let open = ""; try { open = (crD().periodStatus && crD().periodStatus().open) || ""; } catch (e) {}
if (/^\d{4}-\d{2}$/.test(open)) set.add(open);
return { list: Array.from(set).sort().reverse(), open };
};
const crMonthName = (p) => new Date(+p.slice(0, 4), +p.slice(5, 7) - 1, 1).toLocaleDateString("en-US", { month: "long", year: "numeric" });
const crTbGroup = (a) => { const m = /^(\d{3})/.exec(String(a)); return m ? Math.floor(+m[1] / 100) : -1; };
const crTbRoot = (a) => String(a).slice(0, 3);
// month = "YYYY-MM"; asOfISO = "YYYY-MM-DD" ("" → end of month); filter = account search box.
const crTbModel = (month, asOfISO, filter) => {
const year = month.slice(0, 4), cutoff = asOfISO ? crDate(asOfISO) : null;
const acc = {}; let openN = 0, skipped = 0, used = 0;
// an opening-balance entry that was undone / reversed: its reversal (src "Adjustment", j.reverses = the opening entry) is not this month's activity either
const openIds = new Set(); crJEs().forEach(j => { if (j && j.id != null && crIsOpeningJE(j)) openIds.add(j.id); });
crJEs().forEach(j => {
const p = crPeriod(j);
if (!/^\d{4}-\d{2}$/.test(p)) { skipped++; return; }
if (p > month) return;
if (p === month && cutoff) { const dt = j.date ? crDate(j.date) : null; if (dt && dt > cutoff) return; }
const opening = crIsOpeningJE(j) || !!(j.reverses && openIds.has(j.reverses)); if (opening && !j.reverses && !j.reversedBy) openN++; used++;
(j.lines || []).forEach(l => {
const a = String(l.acct == null ? "" : l.acct).trim(); if (!a) return;
const v = crNum(l.dr) - crNum(l.cr);
const o = acc[a] || (acc[a] = { acct: a, mtd: 0, ytd: 0, prior: 0 });
if (crIsPL(a)) { if (p.slice(0, 4) === year) o.ytd += v; else o.prior += v; } else o.ytd += v;
if (p === month && !opening) o.mtd += v;
});
});
const coa = crCOA();
const all = Object.values(acc).map(o => ({ acct: o.acct, mtd: crR2(o.mtd), ytd: crR2(o.ytd), prior: crR2(o.prior), desc: String((coa[o.acct] && coa[o.acct].name) || crAcctName(o.acct) || "").toUpperCase(), alt: (coa[o.acct] && coa[o.acct].alt) || "" }))
.filter(r => Math.abs(r.mtd) > 0.004 || Math.abs(r.ytd) > 0.004);
const toks = crParseTokens(filter);
const shown = !toks.length ? all.slice() : all.filter(r => { const acct = r.acct.toLowerCase(), hay = crHay([r.desc, r.alt], []); return toks.every(t => acct.startsWith(t.s) || acct.startsWith(t.h) || crHasTok(hay, t)); });
const gi = (r) => { const g = crTbGroup(r.acct); return g < 0 ? 99 : g; };
const cmp = (a, b) => gi(a) - gi(b) || crTbRoot(a.acct).localeCompare(crTbRoot(b.acct), undefined, { numeric: true }) || a.acct.localeCompare(b.acct, undefined, { numeric: true });
all.sort(cmp); shown.sort(cmp);
const sum = (rs, k) => crR2(rs.reduce((s, r) => s + r[k], 0));
const rows = [];
for (let i = 0; i < shown.length;) {
const g = crTbGroup(shown[i].acct); let j = i; while (j < shown.length && crTbGroup(shown[j].acct) === g) j++;
for (let k = i; k < j;) {
const fam = crTbRoot(shown[k].acct); let e = k; while (e < j && crTbRoot(shown[e].acct) === fam) e++;
for (let x = k; x < e; x++) rows.push({ kind: "acct", r: shown[x] });
if (e - k >= 2) rows.push({ kind: "family", label: fam, mtd: sum(shown.slice(k, e), "mtd"), ytd: sum(shown.slice(k, e), "ytd") });
rows.push({ kind: "gap" });
k = e;
}
const lo = g * 100;
rows.push({ kind: "group", label: g < 0 ? "Other accounts Total" : `Group ${String(lo).padStart(3, "0")}-${String(lo + 99).padStart(3, "0")} Total`, mtd: sum(shown.slice(i, j), "mtd"), ytd: sum(shown.slice(i, j), "ytd") });
i = j;
}
return { month, year, asOfISO, rows, shown, all, filtered: toks.length > 0, mtd: sum(shown, "mtd"), ytd: sum(shown, "ytd"), fullMtd: sum(all, "mtd"), fullYtd: sum(all, "ytd"),
priorPL: sum(all, "prior"), openingEntries: openN, entriesUsed: used, skipped };
};
const crTbHtml = (month, asOfISO, filter) => {
if (!/^\d{4}-\d{2}$/.test(month || "")) return `
TOTAL IS NOT 0.00 (MONTH ${crAmt(m.fullMtd)} · YEAR ${crAmt(m.fullYtd)}). It is exactly the profit / loss of earlier years (${crAmt(m.priorPL)}) that has not been moved to retained earnings - post the year-end transfer in Accounting and this total will read 0.00.
`
: `
TOTAL IS NOT 0.00 (MONTH ${crAmt(m.fullMtd)} · YEAR ${crAmt(m.fullYtd)}). The books are out of balance: look for a journal entry whose debits and credits differ (Journal Document report, last line "TOTAL" in red).
`;
} else why = `
Total reads 0.00 / 0.00 - the ledger is in balance for this month.
`;
const sub = `period ${crMonthName(month)} as of ${crMDY(effective)}${m.filtered ? ` filtered: ${m.shown.length} of ${m.all.length} accounts (${String(filter).trim()})` : ""}`;
return crHead("TRIAL BALANCE", sub) + (m.filtered ? `
FILTERED REPORT - ${m.shown.length} of ${m.all.length} accounts. Family and group totals cover only the accounts shown; the full Total is on the last line.
+ debit / − credit, as on the old print. MONTH TO DATE = activity of ${crEsc(crMonthName(month))}${asOfISO ? " through " + crMDY(asOfISO) : ""}. YEAR TO DATE = profit & loss accounts (0xxx and 400 up): activity since January 1, ${crEsc(m.year)}; balance-sheet accounts: balance through the month.${m.openingEntries ? ` ${m.openingEntries} opening-balance entr${m.openingEntries === 1 ? "y" : "ies"} (imported old-DMS trial balance) are included in YEAR TO DATE / balances but NOT in MONTH TO DATE.` : ""} COUNT is not tracked by K2B and stays blank. Click an account row to open its detail.${m.skipped ? ` ${m.skipped} journal entr${m.skipped === 1 ? "y has" : "ies have"} no usable date and ${m.skipped === 1 ? "is" : "are"} left out.` : ""}
{dq.trim() ? <>{list.length} of {idx.items.length} {cfg.noun} match> : <>All {idx.items.length} {cfg.noun}>}↑ ↓ move · Enter open · Esc close
{rows.length === 0 &&
{dq.trim() ? <>Nothing matches "{q}". Try fewer or shorter words — case, punctuation and accents do not matter.> : <>There are no {cfg.noun} to pick yet.>}
{groups.length ? <>{groups.reduce((s, g) => s + g.total, 0)} matches in {groups.length} place{groups.length === 1 ? "" : "s"}> : "No matches"}↑ ↓ move · Enter open · Esc close
{!groups.length &&
Nothing found for "{dq}". Try fewer or shorter words.