// ── PRICING CONSTANTS ─────────────────────────────────────────── // All rates in CAD. Edit here — calcQuote() reads these only. // ADMIN_FEE_FLOOR = minimum site admin fee regardless of seat count // ADMIN_FEE_MINIMUM = engagement threshold; admin = max(FLOOR, MIN-subtotal) // ADMIN_FEE_ZT = supplement added to admin when ZT is active // TOOL_COST_* = internal cost estimates (VS comparison only, not billed) // IT_SALARY_1/5 = Ottawa benchmark salaries for VS comparison // ───────────────────────────────────────────────────────────────── const ADMIN_FEE_FLOOR = 150; const ADMIN_FEE_MINIMUM = 650; const ADMIN_FEE_ZT = 250; const VOIP_RATES = { basic: 28, standard: 35, premium: 45 }; const VOIP_PHONE_RATE = 15; const VOIP_FAX_RATE = 10; const TOOL_COST_PER_USER = 42; const TOOL_COST_PER_ENDPOINT = 23; const TOOL_COST_MIN = 650; const IT_SALARY_1 = 85000; const IT_SALARY_5 = 420000; // CONTRACT_DISCOUNT: discount applied to MRR based on contract length // m2m = month-to-month (no discount), 12mo = 3% off, 24mo = 5% off const CONTRACT_DISCOUNT = { 'm2m': 0, '12mo': 0.03, '24mo': 0.05 }; const HST_RATE = 0.13; // Ontario HST 13% // --- CALC --- // ── calcQuote() ───────────────────────────────────────────────── // Single source of truth for all pricing. // Returns a plain object with every calculated value. // Called once per update() cycle; result q passed to all sub-functions. // NEVER writes to DOM — pure calculation only. function calcQuote() { const byol = document.getElementById('rateBYOL')?.checked || false; const users = parseInt(document.getElementById('userCount')?.value) || 0; const endpoints = parseInt(document.getElementById('endpointCount')?.value) || 0; const servers = parseInt(document.getElementById('serverCount')?.value) || 0; const addExtHours= document.getElementById('addExtHours')?.checked || false; const addPWM = document.getElementById('addPWM')?.checked || false; const addINKY = document.getElementById('addINKY')?.checked || false; const addZT = document.getElementById('addZT')?.checked || false; const addUSB = document.getElementById('addUSB')?.checked || false; const addBMB = document.getElementById('addBMB')?.checked || false; const ztSeats = parseInt(document.getElementById('ztNetSeats')?.value) || 0; const ztRouters = parseInt(document.getElementById('ztNetRouters')?.value) || 0; const voipTier = (document.querySelector('input[name="voipTier"]:checked') || {}).value || 'basic'; const voipSeats = parseInt(document.getElementById('voipSeats')?.value) || 0; const addVoipPhone = document.getElementById('addVoipPhone')?.checked || false; const addVoipFax = document.getElementById('addVoipFax')?.checked || false; const clientName = document.getElementById('clientName')?.value || ''; const contractTerm = (document.querySelector('input[name="contractTerm"]:checked') || {}).value || 'm2m'; const hstEnabled = document.getElementById('hstToggle')?.checked || false; const oneTimeFee = parseFloat(document.getElementById('oneTimeFee')?.value) || 0; const ztActive = addZT || ztSeats > 0; const baseUserRate = byol ? 110 : 130; const addonRate = (addExtHours ? 25 : 0) + (addPWM ? 9 : 0) + (addINKY ? 5 : 0) + (addZT ? 55 : 0); const totalUserRate= baseUserRate + addonRate; const userBase = users * baseUserRate; const userExt = addExtHours ? users * 25 : 0; const userPWM = addPWM ? users * 9 : 0; const userINKY = addINKY ? users * 5 : 0; const userZT = addZT ? users * 55 : 0; const userTotal = userBase + userExt + userPWM + userINKY + userZT; const endpointBase = endpoints * 35; const serverBase = servers * 120; const endpointUSB = addUSB ? endpoints * 4 : 0; const endpointBMB = addBMB ? endpoints * 25 : 0; const endpointTotal= endpointBase + serverBase + endpointUSB + endpointBMB; const baseSubtotal = userBase + endpointBase + serverBase; const siteAdminBase = Math.max(ADMIN_FEE_FLOOR, ADMIN_FEE_MINIMUM - baseSubtotal); const admin1PWM = addPWM ? Math.round(userPWM * 0.10) : 0; const adminFeeNet = siteAdminBase + (ztActive ? ADMIN_FEE_ZT : 0) + admin1PWM; const ztNetSeats = ztSeats * 25; const ztNetRouters = ztRouters * 100; const ztNetTotal = ztNetSeats + ztNetRouters; const voipSeatRate = VOIP_RATES[voipTier] || 28; const voipSeatsAmt = voipSeats * voipSeatRate; const voipPhoneAmt = addVoipPhone ? voipSeats * VOIP_PHONE_RATE : 0; const voipFaxAmt = addVoipFax ? VOIP_FAX_RATE : 0; const voipTotal = voipSeatsAmt + voipPhoneAmt + voipFaxAmt; const MRR = userTotal + endpointTotal + adminFeeNet + ztNetTotal + voipTotal; const annual = MRR * 12; const perUserAllin = users > 0 ? MRR / users : 0; // Contract term discount const discountPct = CONTRACT_DISCOUNT[contractTerm] || 0; const discountAmt = Math.round(MRR * discountPct); const effectiveMrr = MRR - discountAmt; const effectiveAnnual = effectiveMrr * 12; // HST (Ontario 13%) const hstAmt = hstEnabled ? Math.round(effectiveMrr * HST_RATE) : 0; const mrrWithHst = effectiveMrr + hstAmt; // Per-user cost breakdown: user-driven services vs site overhead spread across users const perUserServices = users > 0 ? Math.round(userTotal / users) : 0; const perUserSiteOvhd = users > 0 ? Math.round((effectiveMrr - userTotal) / users) : 0; return { users, endpoints, servers, ztSeats, ztRouters, voipSeats, voipTier, addVoipPhone, addVoipFax, byol, addPWM, addINKY, addExtHours, addZT, addUSB, addBMB, ztActive, clientName, contractTerm, hstEnabled, oneTimeFee, baseUserRate, totalUserRate, userBase, userPWM, userINKY, userExt, userZT, userTotal, endpointBase, serverBase, endpointUSB, endpointBMB, endpointTotal, ztNetSeats, ztNetRouters, ztNetTotal, voipSeatRate, voipSeatsAmt, voipPhoneAmt, voipFaxAmt, voipTotal, baseSubtotal, siteAdminBase, admin1PWM, adminFeeNet, MRR, annual, perUserAllin, discountPct, discountAmt, effectiveMrr, effectiveAnnual, hstAmt, mrrWithHst, perUserServices, perUserSiteOvhd }; } // --- MAIN UPDATE --- // ── update() ───────────────────────────────────────────────────── // Master DOM update function. Called on every input change. // Sequence: calcQuote → update all displays → renderNudge → // updateSavings → updateVsComparison → updateSectionSummaries // Also wrapped by the mobile IIFE below to sync _m elements. // Do not call renderNudge() directly from other functions; // always go through update() to keep _m panel in sync. function update() { const q = calcQuote(); // ── Onboarding fee: auto = 50% MRR unless manually set or waived ── // 12-month and 24-month contracts auto-waive the onboarding fee. const waivedEl = document.getElementById('onboardingWaived'); const feeEl = document.getElementById('oneTimeFee'); if (waivedEl && (q.contractTerm === '12mo' || q.contractTerm === '24mo')) { waivedEl.checked = true; waivedEl.disabled = true; waivedEl.dataset.autoWaived = '1'; if (feeEl) delete feeEl.dataset.manual; } else if (waivedEl) { waivedEl.disabled = false; if (waivedEl.dataset.autoWaived) { waivedEl.checked = false; delete waivedEl.dataset.autoWaived; } } const waived = waivedEl?.checked || false; let oneTimeFee; if (waived) { oneTimeFee = 0; if (feeEl) { feeEl.value = ''; feeEl.disabled = true; } } else { if (feeEl) feeEl.disabled = false; if (feeEl && !feeEl.dataset.manual) { oneTimeFee = Math.round(q.MRR / 2); feeEl.value = oneTimeFee > 0 ? oneTimeFee : ''; } else { oneTimeFee = parseFloat(feeEl?.value) || 0; } } q.oneTimeFee = oneTimeFee; const { users, endpoints, servers, voipSeats, voipTier, byol, addPWM, addINKY, addExtHours, addZT, addUSB, addBMB, ztActive, clientName, contractTerm, hstEnabled, baseUserRate, userPWM, userINKY, userExt, userZT, userTotal, serverBase, endpointUSB, endpointBMB, endpointTotal, ztNetTotal, voipTotal, baseSubtotal, siteAdminBase, admin1PWM, adminFeeNet, MRR, discountPct, discountAmt, effectiveMrr, effectiveAnnual, hstAmt, mrrWithHst, perUserServices, perUserSiteOvhd } = q; // Client name display ['clientNameDisplay', 'clientNameDisplay_m'].forEach(id => { const el = document.getElementById(id); if (!el) return; el.textContent = clientName || 'Client Name'; el.classList.toggle('placeholder', !clientName); }); // Admin Fee getEl('adminFeeDisplay').textContent = fmt(adminFeeNet) + '/mo'; const fillPct = Math.min(100, Math.max(0, (baseSubtotal / ADMIN_FEE_MINIMUM) * 100)); getEl('floorBar').style.width = fillPct + '%'; getEl('floorProgress').textContent = fmt(baseSubtotal) + ' / ' + fmt(ADMIN_FEE_MINIMUM); const atFloor = baseSubtotal >= ADMIN_FEE_MINIMUM; getEl('floorBar').style.background = atFloor ? 'var(--green)' : 'var(--accent)'; getEl('floorNote').textContent = atFloor ? '✓ Minimum threshold reached — site fee at floor ($150/mo)' : `$${Math.max(0, ADMIN_FEE_MINIMUM - baseSubtotal).toLocaleString()} more in services reduces admin fee further`; getEl('fb-base').textContent = fmt(siteAdminBase); getEl('fb-zt-row').classList.toggle('hidden', !ztActive); getEl('fb-pwm-row').classList.toggle('hidden', !addPWM); getEl('fb-pwm').textContent = '+' + fmt(admin1PWM); getEl('fb-total').textContent = fmt(adminFeeNet); // Sidebar lines const show = (id, val) => { const el = getEl(id); if (!el) return; el.classList.toggle('hidden', !val); }; show('sl-users', users > 0); if (users > 0) { getEl('sl-users-val').textContent = fmt(userTotal); const sub = getEl('sl-users-sub'); sub.style.display = ''; const subParts = [`${users} × ${fmt(baseUserRate)}/user (${byol ? 'BYOL' : 'M365 Incl.'})`]; if (addExtHours) subParts.push(`+ ${fmt(userExt)}/mo ext. hrs`); if (addPWM) subParts.push(`+ ${fmt(userPWM)}/mo 1Password`); if (addINKY) subParts.push(`+ ${fmt(userINKY)}/mo INKY`); if (addZT) subParts.push(`+ ${fmt(userZT)}/mo Zero Trust`); sub.innerHTML = subParts.join('
'); } show('sl-endpoints', endpoints > 0); if (endpoints > 0) { // endpointTotal includes serverBase — display endpoints-only so servers line doesn't double-count const epOnly = endpointTotal - serverBase; getEl('sl-endpoints-val').textContent = fmt(epOnly); const sub = getEl('sl-endpoints-sub'); sub.style.display = ''; const epParts = [`${endpoints} × $35/endpoint`]; if (addBMB) epParts.push(`+ ${fmt(endpointBMB)}/mo Bare Metal Backup`); if (addUSB) epParts.push(`+ ${fmt(endpointUSB)}/mo USB Blocking`); sub.innerHTML = epParts.join('
'); } show('sl-servers', servers > 0); if (servers > 0) getEl('sl-servers-val').textContent = fmt(serverBase); show('sl-zt', ztNetTotal > 0); if (ztNetTotal > 0) getEl('sl-zt-val').textContent = fmt(ztNetTotal); show('sl-voip', voipTotal > 0); if (voipTotal > 0) getEl('sl-voip-val').textContent = fmt(voipTotal); getEl('sl-admin-val').textContent = fmt(adminFeeNet); // MRR + totals — show effective MRR (after term discount) as the headline number getEl('mrrDisplay').textContent = fmt(effectiveMrr); getEl('annualDisplay').textContent = fmt(effectiveAnnual); getEl('perUserRow').style.display = users > 0 ? '' : 'none'; if (users > 0) getEl('perUserDisplay').textContent = fmt(effectiveMrr / users) + '/user'; // Discount row (only shown when a term discount is active) const discountRow = getEl('sl-discount-row'); if (discountRow) { discountRow.classList.toggle('hidden', discountPct === 0); if (discountPct > 0) { const termLabel = contractTerm === '12mo' ? '12-Month' : '24-Month'; const discEl = getEl('sl-discount-val'); if (discEl) discEl.textContent = `−${fmt(discountAmt)}/mo (${termLabel}, ${Math.round(discountPct*100)}% off)`; } } // Base MRR (pre-discount) row — shown only when discount is active const baseMrrRow = getEl('sl-base-mrr-row'); if (baseMrrRow) baseMrrRow.classList.toggle('hidden', discountPct === 0); const baseMrrEl = getEl('sl-base-mrr-val'); if (baseMrrEl && discountPct > 0) baseMrrEl.textContent = fmt(MRR) + '/mo'; // HST row + Total row getEl('sl-hst-row')?.classList.toggle('hidden', !hstEnabled); getEl('sl-hst-total-row')?.classList.toggle('hidden', !hstEnabled); if (hstEnabled) { const hstEl = getEl('sl-hst-val'); if (hstEl) hstEl.textContent = `+${fmt(hstAmt)}`; const totalEl = getEl('sl-hst-total-val'); if (totalEl) totalEl.textContent = fmt(mrrWithHst) + '/mo'; } // Sync mobile HST toggle state const hstToggleMobile = document.getElementById('hstToggle_m'); if (hstToggleMobile) hstToggleMobile.checked = hstEnabled; // Onboarding fee row — show fee, or "WAIVED" savings if waived const _waived = document.getElementById('onboardingWaived')?.checked || false; const _wouldBe = Math.round(q.MRR / 2); ['sl-otf-row', 'sl-otf-row_m'].forEach(rowId => { const row = getEl(rowId); if (!row) return; const isMobile = rowId.endsWith('_m'); const valEl = getEl(isMobile ? 'sl-otf-val_m' : 'sl-otf-val'); if (_waived && _wouldBe > 0) { row.classList.remove('hidden'); row.classList.add('sl-otf-waived'); if (valEl) valEl.textContent = fmt(_wouldBe) + ' WAIVED'; } else if (oneTimeFee > 0) { row.classList.remove('hidden', 'sl-otf-waived'); if (valEl) valEl.textContent = fmt(oneTimeFee) + ' (one-time)'; } else { row.classList.add('hidden'); row.classList.remove('sl-otf-waived'); } }); // Per-user breakdown sub-line const puBreakdown = getEl('perUserBreakdown'); if (puBreakdown) { puBreakdown.classList.toggle('hidden', users === 0); if (users > 0) { puBreakdown.textContent = `${fmt(perUserServices)} user services + ${fmt(perUserSiteOvhd)} site overhead`; } } // Sidebar notes getEl('adminPct').textContent = MRR > 0 ? Math.round(adminFeeNet / MRR * 100) : '—'; getEl('sideNote-m365').classList.toggle('hidden', byol); getEl('sideNote-byol').classList.toggle('hidden', !byol); if (!byol && users > 0) getEl('m365SaveAmt').textContent = fmt(users * 15); // BYOL callouts getEl('byolCalloutGreen').classList.toggle('hidden', byol); getEl('byolCalloutRed').classList.toggle('hidden', !byol); if (byol) getEl('byolRedSavings').textContent = fmt(users * 15); // VoIP tier active state ['basic','standard','premium'].forEach(t => { const seg = getEl('seg-' + t); if (seg) seg.classList.toggle('active', t === voipTier); }); // Contract term active state ['m2m','12mo','24mo'].forEach(t => { const seg = getEl('seg-term-' + t); if (seg) seg.classList.toggle('active', t === contractTerm); }); // Nudges — dynamic dollar values, context-sensitive conditions const nudges = []; if (!addZT && users > 0) nudges.push({ text: `Zero Trust Deny By Default adds enterprise-grade access control — ${fmt(users * 55)}/mo for all ${users} user${users !== 1 ? 's' : ''}. Recommended for any client with remote staff or sensitive data.`, color: 'amber' }); if (!addPWM && users > 0) nudges.push({ text: `1Password Management — one vault for every credential, enforce strong passwords, revoke access instantly when staff leave. Only ${fmt(users * 9)}/mo for ${users} user${users !== 1 ? 's' : ''}.`, color: 'green' }); if (byol && users > 0) nudges.push({ text: `BYOL selected — switching to M365 Included ($130/user) bundles the license and saves the client up to ${fmt(users * 15)}/mo vs retail Microsoft 365 pricing.`, color: 'green' }); if (endpoints > 0 && !addBMB) nudges.push({ text: `Bare Metal Backup protects against ransomware with image-level restore — ${fmt(endpoints * 25)}/mo covers all ${endpoints} endpoint${endpoints !== 1 ? 's' : ''}. Fast, bare-metal recovery if the worst happens.`, color: 'amber' }); if (voipSeats > 0 && voipTier === 'basic') nudges.push({ text: `Standard tier adds HD video, call analytics, and advanced call routing — only ${fmt(voipSeats * (VOIP_RATES.standard - VOIP_RATES.basic))}/mo more for ${voipSeats} seat${voipSeats !== 1 ? 's' : ''}. Most clients upgrade within 6 months.`, color: 'green' }); if (servers > 0 && !addBMB) nudges.push({ text: `Servers are in scope but Bare Metal Backup is not selected — adding BMB on ${endpoints} endpoint${endpoints !== 1 ? 's' : ''} would also cover server backup in a complete DR story.`, color: 'amber' }); if (users > 0 && endpoints > 0 && endpoints > users * 1.5) nudges.push({ text: `${endpoints} endpoints vs ${users} users — that's a high endpoint-to-user ratio. Consider whether unmanaged devices should be attached to user seats to ensure full coverage.`, color: 'amber' }); window._nudges = nudges; if (!window._nudgeIndex || window._nudgeIndex >= nudges.length) window._nudgeIndex = 0; renderNudge(); updateSavings(q); updateVsComparison(q); updateSectionSummaries(q); debouncedSave(); } // ── toggleSection(id) ──────────────────────────────────────────── // Collapses/expands a numbered section card. // Adds/removes .sec-open on the section element. // .sec-open → chevron rotates 180deg (CSS), body shown (JS display). // Calls updateSectionSummaries() to show/hide summary badges. function toggleSection(id) { const section = document.getElementById(id); const body = document.getElementById(id + '-body'); if (!section || !body) return; const isOpen = section.classList.toggle('sec-open'); body.style.display = isOpen ? '' : 'none'; updateSectionSummaries(); } // ── updateSectionSummaries(q) ──────────────────────────────────── // Shows/hides summary badges on collapsed sections. // setSummary(id, text) → display:inline-block when section collapsed // and text is non-empty; display:none otherwise. // Called by update() and toggleSection(). function updateSectionSummaries(q) { q = q || calcQuote(); const collapsed = id => !document.getElementById(id)?.classList.contains('sec-open'); const setSummary = (id, text) => { const el = document.getElementById(id); if (!el) return; const secId = id.replace('-summary','').replace('sec0','sec-0'); const show = collapsed(secId) && !!text; el.textContent = text || ''; el.style.display = show ? 'inline-block' : 'none'; }; setSummary('sec01-summary', fmt(q.adminFeeNet) + '/mo'); setSummary('sec02-summary', q.users > 0 ? `${q.users} user${q.users !== 1 ? 's' : ''} · ${fmt(q.userTotal)}/mo` : ''); setSummary('sec03-summary', q.endpoints > 0 ? `${q.endpoints} endpoint${q.endpoints !== 1 ? 's' : ''} · ${fmt(q.endpointTotal - q.serverBase)}/mo` : ''); setSummary('sec04-summary', q.servers > 0 ? `${q.servers} server${q.servers !== 1 ? 's' : ''} · ${fmt(q.serverBase)}/mo` : ''); const ztItems = []; if (q.ztSeats > 0) ztItems.push(`${q.ztSeats} seat${q.ztSeats !== 1 ? 's' : ''}`); if (q.ztRouters > 0) ztItems.push(`${q.ztRouters} device${q.ztRouters !== 1 ? 's' : ''}`); setSummary('sec05-summary', q.ztNetTotal > 0 ? ztItems.join(' · ') + ` · ${fmt(q.ztNetTotal)}/mo` : ''); setSummary('sec06-summary', q.voipSeats > 0 ? `${q.voipSeats} seat${q.voipSeats !== 1 ? 's' : ''} · ${fmt(q.voipTotal)}/mo` : ''); } // ── toggleCollapsible(id) ───────────────────────────────────────── // Collapses/expands inner content panels (What's Included, Add-Ons). // Separate from section-level toggle. Toggles .open on .collapsible-body. // Also toggles preview pills (shown when collapsed, hidden when open). function toggleCollapsible(id) { const body = document.getElementById(id); const icon = document.getElementById(id + '-icon'); const preview = document.getElementById(id + '-preview'); if (!body) return; const open = body.classList.toggle('open'); if (icon) icon.classList.toggle('open', open); if (preview) preview.style.display = open ? 'none' : 'flex'; } // ── toggleAddon(checkId, rowId) ───────────────────────────────── // Flips the hidden checkbox + toggles .selected on the visible row. // Called via onclick on the