'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') const muni = require('./municipality') // résolveur de villes canoniques QC (nom officiel + code + MRC) 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(',') // A non-empty company name means a business even when the legacy `commercial` // flag wasn't set — 98 such accounts (Ferme X Inc., Assurances …, Ville de // Farnham, Les Jardins Sorel) leaked into "residential". So the rule is: // commercial = (commercial flag set) OR (company name present). Residential // therefore excludes any account carrying a company name. const HAS_COMPANY = "(a.company IS NOT NULL AND TRIM(a.company) <> '')" let commercialClause = '' if (segment === 'residential') commercialClause = `AND a.commercial = 0 AND NOT ${HAS_COMPANY}` else if (segment === 'commercial') commercialClause = `AND (a.commercial = 1 OR ${HAS_COMPANY})` 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 copy is a one-shot snapshot (no auto-sync), so the // report advertises its as-of date so nobody acts on stale prices. // // This report reads SERVICES (active plans/discounts), not invoices, so the // relevant freshness is the newest service.date_orig — NOT the newest // invoice date. They differ: the snapshot carries services created up to // ~May 22 but invoices only through Apr 30 (May's recurring billing run // hadn't happened at dump time). Using the invoice date understated // freshness by ~3 weeks. We return both; the UI shows the service one. // (A discount added after the snapshot — e.g. Marc Robidoux's loyalty // credit, service id 74448 > the copy's max 74393 — still won't appear // until the copy is refreshed.) async function fetchDataAsOf (pool) { const out = { services: null, invoices: null } try { const [s] = await pool.execute('SELECT MAX(date_orig) AS max_ts FROM service') if (s?.[0]?.max_ts) out.services = new Date(s[0].max_ts * 1000).toISOString() } catch { /* ignore */ } try { const [i] = await pool.execute('SELECT MAX(date_orig) AS max_ts FROM invoice') if (i?.[0]?.max_ts) out.invoices = new Date(i[0].max_ts * 1000).toISOString() } catch { /* ignore */ } return out } 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 asOf = 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 = service freshness (the relevant one for this report); // last_invoice kept for reference. data_as_of: asOf.services, last_invoice: asOf.invoices, 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 }) } } // ───────────────────────────────────────────────────────────────────────────── // EXPLORATEUR DE REVENUS — un seul rapport « revenu récurrent (MRR) par X », // où X (la DIMENSION) est choisie côté client. Remplace les rapports F fixes // séparés (report_client_revenu / rapport_ville / report_count_service / // report_count_res_com / rapport_poste …) par un group-by paramétrable. // // GET /reports/legacy/revenue-explorer // ?dimension = category | zip | city | segment | plan (défaut category) // &segment = residential | commercial | all (défaut all) // &type = internet | tv | phone | all (défaut all) // &active = 1 (défaut) | 0 // &threshold = 0 (net MRR minimum par groupe pour l'inclure) // &limit = 300 // GET /reports/legacy/revenue-explorer.csv (mêmes params) // // Métrique = MRR net = Σ prix effectif mensuel (EFF) des lignes de service // récurrentes actives (mêmes exclusions que le rapport internet-cher : // group_id=5, pas PROPRIO, price_recurr_type=1, rabais expirés écartés). // ───────────────────────────────────────────────────────────────────────────── // Noms de catégories F (product.category est numérique). Source : en-tête de ce // fichier + LEGACY-ACCOUNTING-ANALYSIS. Inconnues → « Catégorie N ». const CATEGORY_NAMES = { 32: 'Mensualités fibre', 4: 'Mensualités sans fil', 23: 'Internet camping', 26: 'Équipement internet fibre', 29: 'Équipement internet fibre', 7: 'Équipement internet sans fil', 8: 'Équipement internet sans fil', 16: 'Téléchargement supplémentaire', 17: 'Adresse IP fixe', 21: 'Location point à point', 9: 'Téléphonie', 33: 'Mensualités télévision', 34: 'Installation/équipement télé', } // Exceptions de libellé de ville (« fichier de traduction ») : quand le nom officiel/dominant // n'est pas celui qu'on veut afficher. Clé = libellé calculé, valeur = libellé voulu. À enrichir au besoin. const CITY_LABEL_OVERRIDE = { 'Sainte-Clotilde-de-Châteauguay': 'Sainte-Clotilde', } const HAS_COMPANY = "(a.company IS NOT NULL AND TRIM(a.company) <> '')" const EXPLORER_DIMS = { category: 'p.category', zip: "COALESCE(NULLIF(TRIM(d.zip),''),'(sans code postal)')", city: "COALESCE(NULLIF(TRIM(d.city),''),'(sans ville)')", segment: `CASE WHEN (a.commercial = 1 OR ${HAS_COMPANY}) THEN 'Commercial' ELSE 'Résidentiel' END`, plan: "COALESCE(NULLIF(TRIM(p.name),''),p.sku,'(sans nom)')", } const DIM_LABELS = { category: 'Catégorie', zip: 'Code postal', city: 'Ville', segment: 'Segment', plan: 'Plan', mrc: 'MRC' } // city + mrc : regroupés en SQL par d.city (ville brute), PUIS canonicalisés/agrégés en JS via le résolveur muni // (unifie « Saint-Anicet » / « St-Anicet » / « Sainte Clotilde » / « Ste-Clotilde »… → un seul nom officiel). const VALID_DIMS = ['category', 'zip', 'city', 'segment', 'plan', 'mrc'] const TYPE_CATS = { internet: [...CAT_INTERNET_CORE, ...CAT_INTERNET_ADDONS], tv: [33, 34], phone: [9], } function buildExplorerQuery (url) { const dimParam = url.searchParams.get('dimension') const dimension = VALID_DIMS.includes(dimParam) ? dimParam : 'category' const dimCol = EXPLORER_DIMS[dimension === 'mrc' ? 'city' : dimension] // mrc/city → SQL par ville, canonicalisé en JS const segment = url.searchParams.get('segment') || 'all' const type = url.searchParams.get('type') || 'all' const active = url.searchParams.get('active') !== '0' const threshold = Math.max(0, parseFloat(url.searchParams.get('threshold') || '0') || 0) const limit = Math.min(1000, parseInt(url.searchParams.get('limit') || '300', 10)) let segmentClause = '' if (segment === 'residential') segmentClause = `AND a.commercial = 0 AND NOT ${HAS_COMPANY}` else if (segment === 'commercial') segmentClause = `AND (a.commercial = 1 OR ${HAS_COMPANY})` const typeCats = TYPE_CATS[type] || null const typeClause = typeCats ? `AND p.category IN (${typeCats.map(() => '?').join(',')})` : '' const WHERE = ` FROM account a JOIN delivery d ON d.account_id = a.id JOIN service s ON s.delivery_id = d.id ${active ? 'AND s.status = 1' : ''} JOIN product p ON p.id = s.product_id WHERE a.group_id = 5 AND a.customer_id NOT LIKE 'PROPRIO%' ${active ? 'AND a.status = 1' : ''} 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()) ${typeClause} ${segmentClause}` const sql = ` SELECT ${dimCol} AS dim_key, ROUND(SUM(${EFF}), 2) AS net, 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(DISTINCT d.id) AS n_addresses, COUNT(*) AS n_lines ${WHERE} GROUP BY dim_key ${threshold > 0 ? 'HAVING net >= ?' : ''} ORDER BY net DESC LIMIT ?` const totalSql = ` SELECT ROUND(SUM(${EFF}), 2) AS net, 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(DISTINCT d.id) AS n_addresses, COUNT(*) AS n_lines ${WHERE}` const baseParams = typeCats ? [...typeCats] : [] const params = [...baseParams] if (threshold > 0) params.push(threshold) params.push(limit) return { sql, params, totalSql, totalParams: baseParams, meta: { dimension, segment, type, active, threshold, limit } } } function labelOf (meta, key) { return meta.dimension === 'category' ? (CATEGORY_NAMES[key] || ('Catégorie ' + key)) : String(key) } function round2 (x) { return Math.round((Number(x) || 0) * 100) / 100 } // Finalise les groupes. Pour city/mrc : canonicalise chaque ville brute via le résolveur muni // (nom officiel QC + MRC) et RÉAGRÈGE — les variantes d'un même nom fusionnent en une seule ligne. // Pour les autres dimensions : applique simplement le libellé. function titleCase (s) { return String(s || '').toLowerCase().replace(/\b([a-zàâäéèêëîïôöùûüç])/g, c => c.toUpperCase()) } async function finalizeRows (rows, meta) { if (meta.dimension !== 'city' && meta.dimension !== 'mrc') { return rows.map(r => ({ ...r, dim_label: labelOf(meta, r.dim_key) })) } try { await muni.load() } catch { /* résolveur indispo → on garde les villes brutes */ } const merged = {} for (const r of rows) { let res = null try { res = muni.resolveSync(r.dim_key) } catch { res = null } const raw = String(r.dim_key || '').trim() let key if (meta.dimension === 'mrc') key = (res && res.matched && res.mrc) ? res.mrc : '(hors MRC)' else key = (res && res.matched && res.canonical) ? res.canonical : (raw || '(sans ville)') const g = merged[key] || (merged[key] = { dim_key: key, dim_label: null, _bestN: -1, net: 0, gross: 0, discounts: 0, n_addresses: 0, n_lines: 0 }) // Libellé ville = la graphie RÉELLE dominante du groupe (ex. « Saint-Anicet », pas « ST ANICET »). const n = Number(r.n_addresses || 0) if (meta.dimension === 'city' && raw && n > g._bestN) { g._bestN = n; g.dim_label = raw } g.net += Number(r.net || 0); g.gross += Number(r.gross || 0); g.discounts += Number(r.discounts || 0) g.n_addresses += n; g.n_lines += Number(r.n_lines || 0) } return Object.values(merged) .map(g => ({ dim_key: g.dim_key, dim_label: meta.dimension === 'mrc' ? titleCase(g.dim_key) : (CITY_LABEL_OVERRIDE[g.dim_label || g.dim_key] || g.dim_label || g.dim_key), net: round2(g.net), gross: round2(g.gross), discounts: round2(g.discounts), n_addresses: g.n_addresses, n_lines: g.n_lines, })) .sort((a, b) => b.net - a.net) } // Cache stale-while-revalidate : la requête d'agrégation F (~2 s) est chère mais les revenus bougent lentement (sync horaire). // Clé = signature (dimension + filtres). Frais → instantané ; périmé → sert + rafraîchit en fond ; à froid → requête live. const _explorerCache = new Map() const EXPLORER_TTL = 10 * 60 * 1000 // 10 min async function runExplorer (url) { const { sql, params, totalSql, totalParams, meta } = buildExplorerQuery(url) const pool = getPool() const t0 = Date.now() const [rows] = await pool.execute(sql, params) const [totalRows] = await pool.execute(totalSql, totalParams) const asOf = await fetchDataAsOf(pool) const labelled = await finalizeRows(rows, meta) log(`revenue-explorer dim=${meta.dimension} seg=${meta.segment} type=${meta.type}: ${rows.length} raw → ${labelled.length} groups in ${Date.now() - t0}ms`) return { dimension: meta.dimension, dimension_label: DIM_LABELS[meta.dimension], segment: meta.segment, type: meta.type, active: meta.active, threshold: meta.threshold, data_as_of: asOf.services, count: labelled.length, total: totalRows[0] || null, rows: labelled, } } async function handleExplorer (req, res, url) { const pool = getPool() if (!pool) return json(res, 503, { error: 'mysql2 not installed on hub' }) const key = url.search || '?' // signature de la requête (dim + filtres) const now = Date.now(); const hit = _explorerCache.get(key) if (hit) { if (now - hit.ts > EXPLORER_TTL && !hit.refreshing) { // périmé → rafraîchit en fond, sert le cache tout de suite hit.refreshing = true runExplorer(url).then(d => _explorerCache.set(key, { data: d, ts: Date.now() })).catch(() => {}).finally(() => { hit.refreshing = false }) } return json(res, 200, { ...hit.data, cached: true }) } try { const data = await runExplorer(url) _explorerCache.set(key, { data, ts: Date.now() }) if (_explorerCache.size > 60) { const oldest = [..._explorerCache.entries()].sort((a, b) => a[1].ts - b[1].ts)[0]; if (oldest) _explorerCache.delete(oldest[0]) } // borne le cache return json(res, 200, data) } catch (e) { log(`revenue-explorer error: ${e.message}`) return json(res, 500, { error: 'legacy query failed: ' + e.message }) } } async function handleExplorerCsv (req, res, url) { const pool = getPool() if (!pool) return json(res, 503, { error: 'mysql2 not installed on hub' }) const { sql, params, meta } = buildExplorerQuery(url) try { const [rawRows] = await pool.execute(sql, params) const rows = await finalizeRows(rawRows, meta) const headers = [DIM_LABELS[meta.dimension], 'net_mrr', 'brut', 'rabais', 'n_adresses', 'n_lignes'] 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([esc(r.dim_label), r.net, r.gross, r.discounts, r.n_addresses, r.n_lines].join(',')) const csv = '' + lines.join('\r\n') + '\r\n' res.writeHead(200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="revenus-${meta.dimension}-${meta.segment}.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) if (path === '/reports/legacy/revenue-explorer' && method === 'GET') return handleExplorer(req, res, url) if (path === '/reports/legacy/revenue-explorer.csv' && method === 'GET') return handleExplorerCsv(req, res, url) return json(res, 404, { error: 'legacy report not found' }) } module.exports = { handle }