'use strict' /** * legacy-reports.js — analytical reports that query the LEGACY MariaDB * (gestionclient) directly, read-only. Distinct from lib/reports.js which * targets the migrated ERPNext PostgreSQL data. * * Connection reuses cfg.LEGACY_DB_* (same vars as auth.js sync-legacy). * A small pool is kept alive across requests for snappy report loads. * * Routes: * GET /reports/legacy/overpriced-internet?threshold=90&segment=residential&addons=1 * → service addresses whose net MONTHLY Internet bill exceeds a * threshold, discounts included. Used by Ops to spot clients who * could be moved to a better-fit plan. * GET /reports/legacy/overpriced-internet.csv (same params) * * Data model recap (validated 2026-06): * account (commercial: 0=residential, 1=commercial) * → delivery (account_id) one row per service address * → service (delivery_id, status=1) active service line * → product (category, price) the plan; price<0 = recurring discount * * Account-level filters (mirror the legacy recurring-billing logic in * LEGACY-ACCOUNTING-ANALYSIS.md §6.1, which bills only when BOTH * service.status=1 AND account.status=1): * account.status = 1 → active account. status=4 is terminated * (8602 accounts, most with a terminate_date, * e.g. Or Viande Inc closed in 2014 but still * carrying an orphan service.status=1 line). * account.group_id = 5 → "Client" per the account_group table. Excludes * 6 Prospect, 7 Fournisseur, 8 Relais (network * infrastructure accounts like a tower-host), * 10 Équipement motorisé — none are billable * residential/commercial customers. * customer_id NOT LIKE → 59 "PROPRIO*" accounts sit inside group 5 but * 'PROPRIO%' are landowners hosting our relay/antenna gear * under a special arrangement (e.g. Denis * Henderson "PROPRIOH_STCHARLES"), not regular * paying customers. Excluded by their id prefix. * * Effective monthly price of a service line: * base = service.hijack=1 ? service.hijack_price : product.price * // product.price is ALREADY the monthly unit price regardless of the * // service's billing frequency — a semestrial FTTH1500I has price * // 109.95 (same as the monthly one) and is billed 6×109.95 every 6 * // months. Verified against the legacy billing logic (LEGACY- * // ACCOUNTING-ANALYSIS.md §6.1: "prix = quantité × prix_unitaire"). * // So we do NOT divide by service.payment_recurrence. * // The one exception: true annual plans (SKU ends in "ANN", e.g. * // FTTH_ANN @ 480$/yr where price IS the yearly amount) → normalize /12. * monthly = base / (sku LIKE '%ANN' ? 12 : 1) * * Only recurring lines count: product.price_recurr_type = 1. Type 0 is * one-time charges (equipment, installation) which don't belong on a * recurring monthly bill. * * Expired credits excluded: a discount line (price<0) whose service * actif_until is in the past no longer reduces the real bill — counting * it would understate what the client actually pays today. (Per * LEGACY-ACCOUNTING-ANALYSIS.md §10: promo credits carry an actif_until * end date.) * * Internet product categories: * monthly: 32 Mensualités fibre, 4 Mensualités sans-fil, 23 camping * equipment: 26/29 équipement fibre, 7/8 équipement sans-fil * (recurring modem/router rentals + Internet discounts that * live here, e.g. RAB_FTTH_URBA). One-time install charges * in these cats are dropped by the price_recurr_type=1 filter. * add-ons: 16 Téléch. supp, 17 IP fixe, 21 Location point-à-point * Excluded entirely: 9 Téléphonie, 33 Télévision, 34 Install télé */ const cfg = require('./config') const { log, json } = require('./helpers') let mysql try { mysql = require('mysql2/promise') } catch { /* optional dep */ } // Internet category sets. CORE = monthly plans + Internet equipment. // The equipment categories (26/29 fibre, 7/8 wireless) carry recurring // modem/router rentals (FTTH_LOCMOD +10, LOC_TPL +5) AND recurring Internet // discounts (RAB_FTTH_URBA), which are part of the real Internet bill — a // client's net Internet cost is wrong without them. Example: Claude Bergeron // shows 94.95 from cat 32 alone, but a -60$ RAB_FTTH_URBA discount lives in // cat 26, so his true net is 44.95 (and he correctly drops off the report). // The price_recurr_type=1 filter still excludes one-time install charges // (INSTFIBRE -199, etc.) that share these equipment categories. const CAT_INTERNET_CORE = [32, 4, 23, 26, 29, 7, 8] const CAT_INTERNET_ADDONS = [16, 17, 21] // extra download, static IP, point-to-point // Always excluded: 9 Téléphonie, 33 Télévision, 34 Installation/équip télé. // Monthly-normalized effective price expression (see header comment). const EFF = `((CASE WHEN s.hijack=1 THEN s.hijack_price ELSE p.price END) / (CASE WHEN p.sku LIKE '%ANN' THEN 12 ELSE 1 END))` let _pool = null function getPool () { if (!mysql) return null if (!_pool) { _pool = mysql.createPool({ host: cfg.LEGACY_DB_HOST, user: cfg.LEGACY_DB_USER, password: cfg.LEGACY_DB_PASS, database: cfg.LEGACY_DB_NAME, connectionLimit: 3, waitForConnections: true, queueLimit: 0, idleTimeout: 60000, enableKeepAlive: true, }) } return _pool } // Build the shared SQL + params from the request's query string. Used by // both the JSON and CSV handlers so the two never drift apart. function buildQuery (url) { const threshold = Math.max(0, parseFloat(url.searchParams.get('threshold') || '90')) const segment = url.searchParams.get('segment') || 'residential' // residential | commercial | all const includeAddons = url.searchParams.get('addons') !== '0' const limit = Math.min(5000, parseInt(url.searchParams.get('limit') || '2000', 10)) const cats = includeAddons ? [...CAT_INTERNET_CORE, ...CAT_INTERNET_ADDONS] : CAT_INTERNET_CORE const catPlaceholders = cats.map(() => '?').join(',') let commercialClause = '' if (segment === 'residential') commercialClause = 'AND a.commercial = 0' else if (segment === 'commercial') commercialClause = 'AND a.commercial = 1' const sql = ` SELECT a.id AS account_id, a.customer_id AS customer_id, TRIM(CONCAT(COALESCE(a.first_name,''),' ',COALESCE(a.last_name,''))) AS client_name, a.company AS company, a.email AS email, a.cell AS cell, a.tel_home AS tel_home, a.commercial AS commercial, d.id AS delivery_id, d.address1 AS address1, d.city AS city, d.zip AS zip, ROUND(SUM(${EFF}), 2) AS net_internet, ROUND(SUM(CASE WHEN (${EFF}) > 0 THEN (${EFF}) ELSE 0 END), 2) AS gross, ROUND(SUM(CASE WHEN (${EFF}) < 0 THEN (${EFF}) ELSE 0 END), 2) AS discounts, COUNT(*) AS n_lines, SUBSTRING(GROUP_CONCAT( CONCAT(p.sku, '=', FORMAT(${EFF}, 2)) ORDER BY (${EFF}) DESC SEPARATOR ' · ' ), 1, 500) AS detail, -- Recently-expired discounts on this address: deactivated credit -- lines (status=0, price<0) whose actif_until fell in the last 180 -- days. Explains a sudden bill jump (e.g. Julie Dupuis lost -- RAB24M -15 + RAB_X -35 on 2026-03-01 → bill went 103→145$). The -- prime retention signal — these clients are about to see the -- increase and may shop around. (SELECT ROUND(SUM(CASE WHEN s2.hijack=1 THEN s2.hijack_price ELSE p2.price END), 2) FROM service s2 JOIN product p2 ON p2.id = s2.product_id WHERE s2.delivery_id = d.id AND s2.status = 0 AND p2.price < 0 AND s2.actif_until IS NOT NULL AND s2.actif_until < UNIX_TIMESTAMP() AND s2.actif_until > (UNIX_TIMESTAMP() - 15552000) ) AS recent_expired_discount FROM account a JOIN delivery d ON d.account_id = a.id JOIN service s ON s.delivery_id = d.id AND s.status = 1 JOIN product p ON p.id = s.product_id WHERE p.category IN (${catPlaceholders}) AND a.status = 1 AND a.group_id = 5 AND a.customer_id NOT LIKE 'PROPRIO%' AND p.price_recurr_type = 1 AND NOT (p.price < 0 AND s.actif_until IS NOT NULL AND s.actif_until > 0 AND s.actif_until < UNIX_TIMESTAMP()) ${commercialClause} GROUP BY d.id HAVING net_internet > ? ORDER BY net_internet DESC LIMIT ?` const params = [...cats, threshold, limit] return { sql, params, meta: { threshold, segment, includeAddons, cats, limit } } } // Freshness probe — the most recent invoice date in the legacy copy tells // us how stale the data is. The copy is a one-shot snapshot (no auto-sync), // so the report must advertise its as-of date to avoid misleading the // operator into acting on month-old prices (a re-negotiated client like // Julie Dupuis won't show their new discount until the copy is refreshed). async function fetchDataAsOf (pool) { try { const [r] = await pool.execute('SELECT MAX(date_orig) AS max_ts FROM invoice') const ts = r?.[0]?.max_ts return ts ? new Date(ts * 1000).toISOString() : null } catch { return null } } async function handleJson (req, res, url) { const pool = getPool() if (!pool) return json(res, 503, { error: 'mysql2 not installed on hub' }) const { sql, params, meta } = buildQuery(url) try { const t0 = Date.now() const [rows] = await pool.execute(sql, params) const dataAsOf = await fetchDataAsOf(pool) log(`legacy report overpriced-internet: ${rows.length} rows in ${Date.now() - t0}ms (threshold=${meta.threshold}, segment=${meta.segment}, addons=${meta.includeAddons})`) return json(res, 200, { threshold: meta.threshold, segment: meta.segment, include_addons: meta.includeAddons, categories: meta.cats, data_as_of: dataAsOf, count: rows.length, rows, }) } catch (e) { log(`legacy report error: ${e.message}`) return json(res, 500, { error: 'legacy query failed: ' + e.message }) } } async function handleCsv (req, res, url) { const pool = getPool() if (!pool) return json(res, 503, { error: 'mysql2 not installed on hub' }) const { sql, params, meta } = buildQuery(url) try { const [rows] = await pool.execute(sql, params) const headers = ['account_id', 'customer_id', 'client_name', 'company', 'email', 'cell', 'tel_home', 'address1', 'city', 'zip', 'net_internet', 'gross', 'discounts', 'recent_expired_discount', 'n_lines', 'detail'] const esc = (v) => { if (v == null) return '' const s = String(v) return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s } const lines = [headers.join(',')] for (const r of rows) lines.push(headers.map(h => esc(r[h])).join(',')) const csv = '' + lines.join('\r\n') + '\r\n' res.writeHead(200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="internet-cher-${meta.segment}-${meta.threshold}.csv"`, 'Cache-Control': 'no-store', }) return res.end(csv) } catch (e) { return json(res, 500, { error: 'legacy query failed: ' + e.message }) } } async function handle (req, res, method, path, url) { if (path === '/reports/legacy/overpriced-internet' && method === 'GET') return handleJson(req, res, url) if (path === '/reports/legacy/overpriced-internet.csv' && method === 'GET') return handleCsv(req, res, url) return json(res, 404, { error: 'legacy report not found' }) } module.exports = { handle }