/* © 2026 K2B NETWORK SOLUTIONS LLC. All rights reserved. */ /* ──────────────────────────────────────────────────────────────────────────── K2B DMS — THE CUSTOMER NUMBER (RC30, lane cn): ONE rule for the station AND the server. src/lib/custnum.js == web CustNum.jsx.txt (byte for byte; UMD: window.CustNum in the browser, module.exports in node) The owner's rule (2026-10-23): "the 7-digit number is the customer number, that's how we keep the customers' accounts straight — there is no new customer number". The customer # IS the customer's phone number WITHOUT the area code (7 digits), set automatically from the phone. The 10-digit phone stays the contact number. • one account per number: a second person with the same 7 digits AND the same full phone is the same account (a household — spouses, family): both names stay on the account (`names[]`), a deal / RO names the person; • the same 7 digits but ANOTHER area code: the office is asked — never silently merged, never silently split; • no phone at all: K2B issues a 7-digit number starting with 1 (1000001 upward, server-issued, never reused) — a phone number never starts with 1, so it can never collide with anyone's phone; • the number never changes after the account is saved, except by the owner (audited); an old number stays searchable (alias). Pure functions, no I/O. Everything that reads, writes, shows or derives the number calls these — never a regex of its own. ──────────────────────────────────────────────────────────────────────── */ (function (root, factory) { if (typeof module === 'object' && module.exports) module.exports = factory(); else root.CustNum = factory(); })(typeof self !== 'undefined' ? self : this, function () { 'use strict'; const RULE_VERSION = 'cn-1'; const LABEL = 'Customer #'; /** a phone-shaped customer number: the 7 digits of a phone without its area code (a US exchange never starts with 0 or 1) */ const CUST_RE = /^[2-9]\d{6}$/; /** a number K2B issued for a customer without a phone: 1000001 upward (no phone number starts with 1) */ const ISSUED_RE = /^1\d{6}$/; const ISSUED_FIRST = 1000001; const ISSUED_LAST = 1999999; /** the number a customer carried before RC30 (the 10-digit phone) — accepted only as a SEARCH alias, never as a number */ const LEGACY10_RE = /^\d{10}$/; /** the order the phone fields are read when a record's number is derived from its phone */ const PHONE_FIELDS = ['phone', 'cell', 'cellPhone', 'mobile', 'mobilePhone', 'homePhone', 'workPhone', 'phone2', 'altPhone', 'otherPhone', 'businessPhone']; /** the old service system's code words in the customer-account column: not a customer at all */ const CODE_WORDS = { INTERNAL: 'internal', INT: 'internal', SHOP: 'internal', WARRANTY: 'warranty', WARR: 'warranty', WTY: 'warranty', VOID: 'void', VOIDED: 'void', CASH: 'cash', POLICY: 'policy', GOODWILL: 'policy' }; const txt = (v) => (v === undefined || v === null ? '' : String(v)).replace(/[\u0000-\u001f]/g, ' ').trim(); const blank = (v) => v === undefined || v === null || String(v).trim() === ''; const digits = (v) => txt(v).replace(/\D/g, ''); /** a phone as exactly 10 digits (a leading US "1" dropped), else null */ function phone10(v) { let d = digits(v); if (d.length === 11 && d[0] === '1') d = d.slice(1); return d.length === 10 ? d : null; } const isCustNum = (s) => { const t = txt(s); return CUST_RE.test(t) || ISSUED_RE.test(t); }; const isIssued = (s) => ISSUED_RE.test(txt(s)); const isPhoneShaped = (s) => CUST_RE.test(txt(s)); const isLegacy10 = (s) => LEGACY10_RE.test(digits(s)); /** a phone (any format) → { num7, phone10, areaCode } ; no usable phone → { num7: null, phone10: null, areaCode: null } */ function custNumFromPhone(phone) { const p = phone10(phone); if (!p) return { num7: null, phone10: null, areaCode: null }; const num7 = p.slice(3); return { num7: CUST_RE.test(num7) ? num7 : null, phone10: p, areaCode: p.slice(0, 3) }; } /** the first phone field of a record that holds a usable 10-digit phone → its 10 digits (null when none) */ function phoneOfRecord(rec) { if (!rec || typeof rec !== 'object') return null; for (const k of PHONE_FIELDS) { const p = phone10(rec[k]); if (p) return p; } if (isLegacy10(rec.custNum)) return digits(rec.custNum); // a pre-RC30 record: its number was its phone return null; } /** every distinct 10-digit phone on a record */ function phonesOfRecord(rec) { const out = []; if (!rec || typeof rec !== 'object') return out; for (const k of PHONE_FIELDS) { const p = phone10(rec[k]); if (p && out.indexOf(p) === -1) out.push(p); } if (isLegacy10(rec.custNum) && out.indexOf(digits(rec.custNum)) === -1) out.push(digits(rec.custNum)); return out; } /** the number a record SHOULD carry by the rule: from its phone (7 digits) — null when it has no phone (the server issues one) */ function numberOfRecord(rec) { const p = phoneOfRecord(rec); return p ? custNumFromPhone(p).num7 : null; } /** the old system's code word in a customer-account cell → 'internal' | 'warranty' | 'void' | 'cash' | 'policy' | null */ function codeWordOf(s) { const w = txt(s).toUpperCase().replace(/[^A-Z]/g, ''); return w && CODE_WORDS[w] ? CODE_WORDS[w] : null; } /** why a typed / imported number is not a customer number (plain words), or null when it is one */ function shapeProblem(s) { const t = txt(s); if (!t) return 'There is no customer number.'; if (isCustNum(t)) return null; const d = digits(t); if (codeWordOf(t)) return `"${t}" is the old system's code word for ${codeWordOf(t)} work, not a customer.`; if (d.length === 10 || (d.length === 11 && d[0] === '1')) { const r = custNumFromPhone(d); return `"${t}" is a phone with its area code — the customer # is the 7 digits without it${r.num7 ? ' (' + r.num7 + ')' : ''}.`; } if (d.length === 7 && /^[01]/.test(d)) return `"${t}" cannot be a phone number (a phone's 7 digits never start with ${d[0]}) and it was not issued by K2B.`; if (/^\d+$/.test(t)) return `"${t}" has ${d.length} digits — the customer # is the phone number without the area code (7 digits).`; return `"${t}" is not a customer number — the customer # is the phone number without the area code (7 digits).`; } /** a number cell the way a spreadsheet hands it over: "5,551,242" (thousands separators) / "5551242.0" (a numeric column) → "5551242"; * anything else comes back as typed (trimmed) — an exponent form ("5.55124E+06") lost digits and stays as it is, so the rule refuses it (RC30 fix round, mig F6) */ const cellNumber = (v) => { const t = txt(v); const m = /^(\d{1,3}(?:,\d{3})+|\d+)(?:\.0+)?$/.exec(t); return m ? m[1].replace(/,/g, '') : t; }; /** "(573) 555-0123" from 10 digits; anything else returned as typed */ const fmtPhone = (v) => { const p = phone10(v); return p ? `(${p.slice(0, 3)}) ${p.slice(3, 6)}-${p.slice(6)}` : txt(v); }; /** RC31 (fx31, RC30 acceptance F1 / F6): what a TYPED number names, in the order every search reads it (the server's /api/customers/resolve, * the station's search boxes — one reading): [{ kind: 'number' | 'alias' | 'phone', value }] — the first step that finds a live account wins. * number the text as typed is a live customer # (5557001 · a K2B number 1000001) * alias … or an OLD number of one (the 10-digit pre-RC30 number, a folded household's, a number the owner changed) * then the same two for the digits when the text had punctuation ("555-7001") * phone a 10-digit phone in any format ((573) 555-7001, 573-555-7001, 5735557001, +1 573 …): the customer whose phone it IS (also a parked / * K2B-numbered one on another area code), else the account its last 7 digits name (number, then alias) * A word / a short number gives [number, alias] of the text only. * RC31 fix round (fx31 checkers, attacker R1b / R2): each step carries its TIER, read in this order — * 'number' a live customer # (as typed, then its digits): it answers * 'exact' the old numbers (aliases) of the text AND the customer whose phone it IS, judged TOGETHER: the live customer whose own phone it is wins * over another account's old number (DAN's phone (573) 555-7002 finds DAN, not BOB whose OLD number was 5735557002); when both name * the same account the answer is the old-number answer of RC30 * 'last7' the account its last 7 digits name when nobody has the phone itself — a SUGGESTION only (the same 7 digits under another area * code may be another person): a lookup may show it, a write (a consent, a trade) never takes it */ function lookupOrder(typed) { const t = txt(typed); if (!t) return []; const out = []; const seen = new Set(); const add = (kind, value, tier) => { const v = txt(value); if (!v || seen.has(kind + ':' + v)) return; seen.add(kind + ':' + v); out.push({ kind, value: v, tier }); }; add('number', t, 'number'); add('alias', t, 'exact'); if (!/^[\d\s().+\-#]+$/.test(t)) return out; const d = digits(t); if (d && d !== t) { add('number', d, 'number'); add('alias', d, 'exact'); } const p = phone10(t); if (p) { add('alias', p, 'exact'); add('phone', p, 'exact'); const n7 = custNumFromPhone(p).num7; if (n7) { add('number', n7, 'last7'); add('alias', n7, 'last7'); } } return out; } /** the aliases a record carries (old numbers that still find it) */ const aliasesOf = (rec) => (rec && Array.isArray(rec.aliases) ? rec.aliases.map(txt).filter(Boolean) : []); /** a name as one key: "SMITH, JOHN" == "John Smith" */ const nameKey = (first, last) => [txt(first), txt(last)].join(' ').toUpperCase().replace(/[.,'"]/g, '').split(/\s+/).filter(Boolean).sort().join(' '); const splitName = (full) => { const s = txt(full); if (!s) return { first: '', last: '' }; if (s.indexOf(',') !== -1) { const p = s.split(','); return { last: txt(p[0]), first: txt(p.slice(1).join(' ')) }; } const p = s.split(/\s+/); return { first: p[0], last: p.slice(1).join(' ') }; }; /** every person on the account: the primary name (firstName / lastName, else name) first, then names[] — de-duplicated by name key */ function namesOf(rec) { if (!rec || typeof rec !== 'object') return []; const out = []; const seen = new Set(); const push = (first, last, role) => { const k = nameKey(first, last); if (!k || seen.has(k)) return; seen.add(k); out.push({ first: txt(first).toUpperCase(), last: txt(last).toUpperCase(), role: txt(role) || 'primary' }); }; if (!blank(rec.firstName) || !blank(rec.lastName)) push(rec.firstName, rec.lastName, 'primary'); else if (!blank(rec.name)) { const n = splitName(rec.name); push(n.first, n.last, 'primary'); } if (Array.isArray(rec.names)) rec.names.forEach((n) => { if (n && typeof n === 'object') push(n.first !== undefined ? n.first : n.firstName, n.last !== undefined ? n.last : n.lastName, n.role || 'household'); }); return out; } /** is this person already on the account? */ const hasName = (rec, first, last) => { const k = nameKey(first, last); return !!k && namesOf(rec).some((n) => nameKey(n.first, n.last) === k); }; /** "SMITH, JOHN" (+ " & SMITH, JANE" for a household) */ function displayName(rec, opts) { const ns = namesOf(rec); if (!ns.length) return txt(rec && (rec.name || rec.company)).toUpperCase(); const one = (n) => [n.last, n.first].filter(Boolean).join(', '); if (opts && opts.primaryOnly) return one(ns[0]); return ns.map(one).join(' & '); } /** compare two records' phones: 'same' (a 10-digit phone in common), 'different' (both have phones, none in common), 'unknown' (one has none) */ function phoneRelation(a, b) { const pa = phonesOfRecord(a), pb = phonesOfRecord(b); if (!pa.length || !pb.length) return 'unknown'; return pa.some((p) => pb.indexOf(p) !== -1) ? 'same' : 'different'; } /** the server-owned fields of a customer record (a station save never changes them; the commands do) */ const SERVER_FIELDS = ['aliases', 'names', 'numberSource', 'mergedFrom', 'parkedFrom', 'numberHistory', 'settleId', 'splitFrom']; /** words for the office: what a numberSource means */ const SOURCE_WORDS = { phone: 'from the phone', issued: 'issued by K2B (no phone)', parked: 'issued by K2B while the office settles two accounts with the same 7 digits', owner: 'set by the owner', typed: 'typed / imported (the old account number)', migrated: 'from the phone (RC30 renumbering)' }; return { RULE_VERSION, LABEL, CUST_RE, ISSUED_RE, ISSUED_FIRST, ISSUED_LAST, LEGACY10_RE, PHONE_FIELDS, CODE_WORDS, SERVER_FIELDS, SOURCE_WORDS, txt, blank, digits, phone10, isCustNum, isIssued, isPhoneShaped, isLegacy10, custNumFromPhone, phoneOfRecord, phonesOfRecord, numberOfRecord, codeWordOf, shapeProblem, cellNumber, fmtPhone, aliasesOf, nameKey, splitName, namesOf, hasName, displayName, phoneRelation, lookupOrder }; });