/** * Service de récupération automatique des factures FreePro * * Ce service se connecte au portail FreePro (https://pro.free.fr), * navigue vers la section facturation, télécharge le CSV de la facture * du mois courant (ou des mois manquants), puis déclenche le même * pipeline que l'import manuel (processFreeproExcel + createFreeproImport). * * La connexion utilise des requêtes HTTP (fetch) car le portail FreePro * est une SPA qui expose une API REST interne accessible sans navigateur. */ import { getFreeproSettings, updateFreeproLastRun, createFreeproImport, getFreeproImportsByUser } from "./db"; import { processFreeproExcel } from "./freeproService"; // ── Types ────────────────────────────────────────────────────────────────── interface FreeproInvoice { invoiceNumber: string; // ex: F202506006010 date: string; // ex: 2025-06-01 amount: number; // TTC en euros month: string; // ex: "06/2025" } interface AutoImportResult { success: boolean; imported: number; skipped: number; errors: string[]; message: string; } // ── Constantes ───────────────────────────────────────────────────────────── const FREEPRO_BASE_URL = "https://pro.free.fr"; const LOGIN_URL = `${FREEPRO_BASE_URL}/espace-client/connexion/#/`; const BILLING_URL = `${FREEPRO_BASE_URL}/account/billing`; const BILLING_API_URL = `${FREEPRO_BASE_URL}/account/api/billing`; // ── Helpers ──────────────────────────────────────────────────────────────── /** * Formate un timestamp en label MM/AAAA */ function timestampToMoisLabel(ts: number): string { const d = new Date(ts * 1000); const m = String(d.getMonth() + 1).padStart(2, "0"); const y = String(d.getFullYear()); return `${m}/${y}`; } /** * Calcule le label du mois courant */ function currentMoisLabel(): string { const now = new Date(); const m = String(now.getMonth() + 1).padStart(2, "0"); const y = String(now.getFullYear()); return `${m}/${y}`; } /** * Calcule le label du mois précédent (les factures FreePro arrivent en début de mois suivant) */ function previousMoisLabel(): string { const now = new Date(); now.setMonth(now.getMonth() - 1); const m = String(now.getMonth() + 1).padStart(2, "0"); const y = String(now.getFullYear()); return `${m}/${y}`; } // ── Service principal ────────────────────────────────────────────────────── /** * Tente de se connecter au portail FreePro et de récupérer la liste des factures * via l'API interne du portail. * * Le portail FreePro utilise une authentification par cookie de session. * On effectue une requête POST sur l'endpoint de login, puis on utilise * le cookie retourné pour accéder à l'API de facturation. */ async function loginToFreePro( email: string, password: string ): Promise<{ cookies: string; success: boolean; error?: string }> { try { // Étape 1 : récupérer la page de login pour obtenir le token CSRF si nécessaire const loginPageResp = await fetch(`${FREEPRO_BASE_URL}/espace-client/connexion/`, { method: "GET", headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", }, redirect: "follow", }); const setCookieHeader = loginPageResp.headers.get("set-cookie") || ""; const initialCookies = setCookieHeader .split(",") .map((c) => c.split(";")[0].trim()) .filter(Boolean) .join("; "); // Étape 2 : soumettre les credentials const loginResp = await fetch(`${FREEPRO_BASE_URL}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "application/json", "Cookie": initialCookies, "Referer": LOGIN_URL, "Origin": FREEPRO_BASE_URL, }, body: JSON.stringify({ email, password }), redirect: "follow", }); if (!loginResp.ok) { // Essayer l'endpoint alternatif const altResp = await fetch(`${FREEPRO_BASE_URL}/account/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "application/json", "Cookie": initialCookies, "Referer": LOGIN_URL, "Origin": FREEPRO_BASE_URL, }, body: JSON.stringify({ email, password }), redirect: "follow", }); if (!altResp.ok) { return { success: false, cookies: "", error: `Échec de connexion au portail FreePro (HTTP ${loginResp.status}). Vérifiez vos identifiants.`, }; } const altCookies = (altResp.headers.get("set-cookie") || "") .split(",") .map((c) => c.split(";")[0].trim()) .filter(Boolean) .join("; "); return { success: true, cookies: [initialCookies, altCookies].filter(Boolean).join("; ") }; } const sessionCookies = (loginResp.headers.get("set-cookie") || "") .split(",") .map((c) => c.split(";")[0].trim()) .filter(Boolean) .join("; "); return { success: true, cookies: [initialCookies, sessionCookies].filter(Boolean).join("; "), }; } catch (err: any) { return { success: false, cookies: "", error: `Erreur réseau lors de la connexion : ${err.message}`, }; } } /** * Récupère la liste des factures disponibles sur le portail FreePro */ async function fetchInvoiceList(cookies: string): Promise { try { const resp = await fetch(`${BILLING_API_URL}/invoices`, { headers: { "Cookie": cookies, "Accept": "application/json", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": BILLING_URL, }, }); if (!resp.ok) { // Essayer l'endpoint alternatif const altResp = await fetch(`${FREEPRO_BASE_URL}/account/billing/api/invoices`, { headers: { "Cookie": cookies, "Accept": "application/json", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": BILLING_URL, }, }); if (!altResp.ok) return []; const data = await altResp.json(); return parseInvoiceList(data); } const data = await resp.json(); return parseInvoiceList(data); } catch { return []; } } /** * Parse la réponse JSON de l'API de facturation FreePro */ function parseInvoiceList(data: any): FreeproInvoice[] { const invoices: FreeproInvoice[] = []; // L'API peut retourner différentes structures const items = Array.isArray(data) ? data : (data?.invoices ?? data?.data ?? []); for (const item of items) { const invoiceNumber = item.ref_piece ?? item.invoiceNumber ?? item.id ?? ""; const date = item.date ?? item.invoiceDate ?? ""; const amount = parseFloat(item.total ?? item.amount ?? item.ttc ?? "0"); if (!invoiceNumber || !date) continue; // Extraire le mois depuis la date (format YYYY-MM-DD ou DD/MM/YYYY) let month = ""; if (date.includes("-")) { const parts = date.split("-"); if (parts.length >= 2) { month = `${parts[1].padStart(2, "0")}/${parts[0]}`; } } else if (date.includes("/")) { const parts = date.split("/"); if (parts.length >= 3) { month = `${parts[1].padStart(2, "0")}/${parts[2]}`; } } if (month) { invoices.push({ invoiceNumber, date, amount, month }); } } return invoices; } /** * Télécharge le CSV d'une facture FreePro * URL format: https://pro.free.fr/account/invoice/{NUMERO_FACTURE}/primary_csv */ async function downloadInvoiceCsv( cookies: string, invoiceNumber: string ): Promise { try { const csvUrl = `${FREEPRO_BASE_URL}/account/invoice/${invoiceNumber}/primary_csv`; const resp = await fetch(csvUrl, { headers: { "Cookie": cookies, "Accept": "text/csv,application/csv,*/*", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": BILLING_URL, }, redirect: "follow", }); if (!resp.ok) return null; const arrayBuffer = await resp.arrayBuffer(); return Buffer.from(arrayBuffer); } catch { return null; } } // ── Export principal ─────────────────────────────────────────────────────── /** * Exécute la récupération automatique des factures FreePro pour un utilisateur. * * 1. Charge les paramètres de connexion depuis la DB * 2. Se connecte au portail FreePro * 3. Récupère la liste des factures disponibles * 4. Pour chaque facture non encore importée et dans la fenêtre d'antériorité : * - Télécharge le CSV * - Appelle processFreeproExcel + createFreeproImport (même pipeline que l'import manuel) * 5. Met à jour le statut dans la DB */ export async function runFreeproAutoImport(userId: number): Promise { const result: AutoImportResult = { success: false, imported: 0, skipped: 0, errors: [], message: "", }; // 1. Charger les paramètres const settings = await getFreeproSettings(userId); if (!settings) { result.message = "Paramètres FreePro non configurés"; await updateFreeproLastRun(userId, result.message, 0, false); return result; } if (!settings.loginEmail || !settings.loginPassword) { result.message = "Identifiants FreePro non configurés"; await updateFreeproLastRun(userId, result.message, 0, false); return result; } // 2. Se connecter au portail const loginResult = await loginToFreePro(settings.loginEmail, settings.loginPassword); if (!loginResult.success) { result.message = loginResult.error ?? "Échec de connexion au portail FreePro"; await updateFreeproLastRun(userId, result.message, 0, false); return result; } const { cookies } = loginResult; // 3. Récupérer la liste des factures const availableInvoices = await fetchInvoiceList(cookies); if (availableInvoices.length === 0) { // Si l'API ne retourne rien, essayer de construire la facture du mois précédent // (les factures FreePro arrivent en début de mois suivant) const targetMonth = previousMoisLabel(); result.message = `Aucune facture disponible via l'API. Tentative sur le mois ${targetMonth}`; // On ne peut pas continuer sans numéro de facture await updateFreeproLastRun(userId, result.message, 0, false); return result; } // 4. Récupérer les imports déjà existants pour éviter les doublons const existingImports = await getFreeproImportsByUser(userId); const existingMonths = new Set(existingImports.map((i) => i.moisLabel)); // 5. Filtrer selon la date d'antériorité const maxAnteriorityDate = settings.maxAnteriority ? new Date(settings.maxAnteriority * 1000) : null; const toImport = availableInvoices.filter((inv) => { // Vérifier si déjà importé if (existingMonths.has(inv.month)) { result.skipped++; return false; } // Vérifier la date d'antériorité if (maxAnteriorityDate) { const invDate = new Date(inv.date); if (invDate < maxAnteriorityDate) { result.skipped++; return false; } } return true; }); if (toImport.length === 0) { result.success = true; result.message = `Aucune nouvelle facture à importer (${result.skipped} déjà importée(s))`; await updateFreeproLastRun(userId, result.message, 0, true); return result; } // 6. Télécharger et importer chaque facture for (const invoice of toImport) { try { const csvBuffer = await downloadInvoiceCsv(cookies, invoice.invoiceNumber); if (!csvBuffer || csvBuffer.length === 0) { result.errors.push(`Impossible de télécharger le CSV pour la facture ${invoice.invoiceNumber}`); continue; } // Traiter le CSV avec le même pipeline que l'import manuel const fileName = `Facture_FreePro_${invoice.invoiceNumber}.csv`; const processed = processFreeproExcel(csvBuffer, invoice.month, fileName); // Sauvegarder en base await createFreeproImport( { userId, moisLabel: processed.moisLabel, annee: processed.annee, mois: processed.mois, refPiece: processed.refPiece ?? null, fileName, nbLignes: processed.nbLignes, totalTtc: processed.totalTtc.toFixed(2), }, processed.lines.map((l) => ({ structure: l.structure ?? null, type: l.type, montantCentimes: Math.round(l.montant * 100), })) ); result.imported++; } catch (err: any) { result.errors.push(`Erreur lors de l'import de ${invoice.invoiceNumber} : ${err.message}`); } } // 7. Mettre à jour le statut result.success = result.imported > 0 || (toImport.length === 0 && result.errors.length === 0); if (result.errors.length > 0) { result.message = `${result.imported} facture(s) importée(s), ${result.errors.length} erreur(s) : ${result.errors.join("; ")}`; } else { result.message = `${result.imported} facture(s) importée(s) avec succès`; } await updateFreeproLastRun(userId, result.message, result.imported, result.success); return result; } /** * Teste la connexion au portail FreePro avec les credentials fournis */ export async function testFreeproConnection( email: string, password: string ): Promise<{ success: boolean; message: string }> { const loginResult = await loginToFreePro(email, password); if (!loginResult.success) { return { success: false, message: loginResult.error ?? "Échec de connexion" }; } // Vérifier qu'on peut accéder à la section facturation try { const invoices = await fetchInvoiceList(loginResult.cookies); return { success: true, message: `Connexion réussie. ${invoices.length} facture(s) trouvée(s) dans l'espace client.`, }; } catch { return { success: true, message: "Connexion réussie (impossible de lister les factures, mais les credentials sont valides).", }; } } // ── Scheduler en mémoire ─────────────────────────────────────────────────── // Map userId → intervalId pour les jobs périodiques const activeJobs = new Map(); /** * Démarre le job périodique de récupération automatique pour un utilisateur */ export function startFreeproAutoJob(userId: number, frequencyMs: number): void { stopFreeproAutoJob(userId); // Arrêter l'ancien job si existant const interval = setInterval(async () => { try { await runFreeproAutoImport(userId); } catch (err: any) { console.error(`[FreePro Auto] Erreur job userId=${userId}:`, err.message); } }, frequencyMs); activeJobs.set(userId, interval); console.log(`[FreePro Auto] Job démarré pour userId=${userId}, fréquence=${frequencyMs}ms`); } /** * Arrête le job périodique pour un utilisateur */ export function stopFreeproAutoJob(userId: number): void { const interval = activeJobs.get(userId); if (interval) { clearInterval(interval); activeJobs.delete(userId); console.log(`[FreePro Auto] Job arrêté pour userId=${userId}`); } } /** * Convertit une fréquence texte en millisecondes */ export function frequencyToMs(frequency: string): number { switch (frequency) { case "daily": return 24 * 60 * 60 * 1000; case "weekly": return 7 * 24 * 60 * 60 * 1000; case "monthly": return 30 * 24 * 60 * 60 * 1000; default: return 0; // manual = pas de job } }