/** * Service de récupération automatique des factures FreePro * * Le portail FreePro (https://pro.free.fr) est une SPA Angular. * La connexion se fait via un formulaire HTML : il n'existe pas d'endpoint * REST public accessible en fetch simple. * * Stratégie adoptée : * 1. Tenter de télécharger directement le CSV de la facture du mois courant * via l'URL https://pro.free.fr/account/invoice/{REF}/primary_csv après * avoir obtenu un cookie de session via le formulaire de login. * 2. Si l'accès direct échoue, retourner un message d'erreur explicite. * * Pour le test de connexion, on vérifie que la page /account/ est accessible * avec les cookies obtenus — ce qui confirme que les credentials sont valides. */ import { getFreeproSettings, updateFreeproLastRun, createFreeproImport, getFreeproImportsByUser } from "./db"; import { processFreeproExcel } from "./freeproService"; // ── Types ────────────────────────────────────────────────────────────────── interface FreeproInvoice { invoiceNumber: string; date: string; amount: number; month: string; } 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 LOGIN_FORM_URL = `${FREEPRO_BASE_URL}/espace-client/connexion/`; // Endpoint réel capturé via analyse réseau du portail FreePro (XHR POST) const DO_LOGIN_URL = `${FREEPRO_BASE_URL}/account/security/do_login`; const ACCOUNT_URL = `${FREEPRO_BASE_URL}/account/`; const BILLING_URL = `${FREEPRO_BASE_URL}/account/billing`; /** Timeout en ms pour les requêtes HTTP vers FreePro. * L'endpoint do_login peut mettre jusqu'à ~16-17s à répondre. */ const HTTP_TIMEOUT_MS = 25_000; // ── Helpers ──────────────────────────────────────────────────────────────── /** * fetch avec timeout intégré */ async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs = HTTP_TIMEOUT_MS): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { ...options, signal: controller.signal }); } finally { clearTimeout(timer); } } /** * Extrait les cookies d'un header set-cookie */ function extractCookies(setCookieHeader: string | null): string { if (!setCookieHeader) return ""; return setCookieHeader .split(",") .map((c) => c.split(";")[0].trim()) .filter(Boolean) .join("; "); } /** * 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}`; } // ── Connexion au portail FreePro ─────────────────────────────────────────── /** * Tente de se connecter au portail FreePro via le formulaire HTML. * * Le portail est une SPA Angular. La connexion passe par : * 1. GET /espace-client/connexion/ → récupérer les cookies initiaux * 2. POST sur l'endpoint de soumission du formulaire avec les credentials * 3. Vérification que la page /account/ est accessible (HTTP 200 non-redirigé) * * Retourne les cookies de session si la connexion réussit. */ 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 les cookies initiaux ── let initialCookies = ""; try { const loginPageResp = await fetchWithTimeout(LOGIN_FORM_URL, { method: "GET", headers: { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "fr-FR,fr;q=0.9", }, redirect: "follow", }, 10_000); initialCookies = extractCookies(loginPageResp.headers.get("set-cookie")); } catch { // Ignorer les erreurs de la page de login — on continue sans cookies initiaux } // ── Étape 2 : soumettre les credentials sur le vrai endpoint FreePro ── // Endpoint capturé par analyse réseau : POST /account/security/do_login // Payload : { login: string, password: string } (NB: "login" et non "email") let sessionCookies = ""; let loginSuccess = false; try { const loginResp = await fetchWithTimeout(DO_LOGIN_URL, { method: "POST", headers: { "Content-Type": "application/json", "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36", "Accept": "application/json, text/plain, */*", "Accept-Language": "fr-FR,fr;q=0.9", "Cookie": initialCookies, "Referer": LOGIN_FORM_URL, "Origin": FREEPRO_BASE_URL, "X-Requested-With": "XMLHttpRequest", }, // Le portail utilise "login" (pas "email") comme clé d'identifiant body: JSON.stringify({ login: email, password }), redirect: "follow", }, 25_000); if (loginResp.ok || loginResp.status === 302 || loginResp.status === 204) { // Lire le corps pour vérifier l'absence d'erreur // L'API FreePro retourne HTTP 200 même en cas d'échec, avec { error: "invalid_credentials" } let bodyText = ""; try { bodyText = await loginResp.text(); } catch { /* ignorer */ } if (bodyText.includes("invalid_credentials") || bodyText.includes("invalide")) { return { success: false, cookies: "", error: "Identifiants FreePro incorrects (email ou mot de passe invalide).", }; } const newCookies = extractCookies(loginResp.headers.get("set-cookie")); sessionCookies = [initialCookies, newCookies].filter(Boolean).join("; "); loginSuccess = true; } else if (loginResp.status === 401 || loginResp.status === 403) { return { success: false, cookies: "", error: "Identifiants FreePro incorrects (email ou mot de passe invalide).", }; } else { // Lire le corps pour avoir plus d'info let errorMsg = `Échec de connexion (HTTP ${loginResp.status})`; try { const body = await loginResp.text(); if (body.includes("invalid_credentials") || body.includes("invalide") || body.includes("invalid") || body.includes("incorrect")) { errorMsg = "Identifiants FreePro incorrects (email ou mot de passe invalide)."; } } catch { /* ignorer */ } return { success: false, cookies: "", error: errorMsg }; } } catch (err: any) { if (err.name === "AbortError") { return { success: false, cookies: "", error: "Délai d'attente dépassé lors de la connexion au portail FreePro (12s).", }; } return { success: false, cookies: "", error: `Erreur réseau lors de la connexion : ${err.message}`, }; } // ── Étape 3 : vérifier l'accès à /account/ ── // Même si aucun endpoint de login n'a répondu 200, on tente d'accéder à /account/ // car certaines versions du portail redirigent directement après le formulaire HTML. const cookiesToTry = sessionCookies || initialCookies; try { const accountResp = await fetchWithTimeout(ACCOUNT_URL, { method: "GET", headers: { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml", "Cookie": cookiesToTry, "Referer": FREEPRO_BASE_URL, }, redirect: "manual", // Ne pas suivre les redirections pour détecter si on est connecté }, 10_000); // Si on obtient un 200 ou 304, on est connecté if (accountResp.status === 200 || accountResp.status === 304) { return { success: true, cookies: cookiesToTry }; } // Si on est redirigé vers la page de login, les credentials sont invalides const location = accountResp.headers.get("location") ?? ""; if (location.includes("connexion") || location.includes("login")) { return { success: false, cookies: "", error: "Identifiants FreePro incorrects. Vérifiez votre email et mot de passe.", }; } // Redirection vers /account/ elle-même = succès if (location.includes("/account")) { return { success: true, cookies: cookiesToTry }; } } catch (err: any) { if (err.name === "AbortError") { return { success: false, cookies: "", error: "Délai d'attente dépassé lors de la connexion au portail FreePro (15s). Vérifiez que le portail est accessible.", }; } } // Si on arrive ici avec loginSuccess = true, on considère que la connexion a fonctionné if (loginSuccess) { return { success: true, cookies: sessionCookies }; } return { success: false, cookies: "", error: "Impossible de vérifier la connexion au portail FreePro. Le portail est peut-être inaccessible ou a changé de structure.", }; } catch (err: any) { if (err.name === "AbortError") { return { success: false, cookies: "", error: "Délai d'attente dépassé lors de la connexion au portail FreePro.", }; } return { success: false, cookies: "", error: `Erreur réseau lors de la connexion : ${err.message}`, }; } } // ── Récupération de la liste des factures ────────────────────────────────── /** Endpoint API REST interne du portail FreePro (Angular) qui retourne la liste des factures en JSON. */ const INVOICES_API_URL = `${FREEPRO_BASE_URL}/api/api_red_october/v1/invoices`; /** * Récupère la liste des factures disponibles sur le portail FreePro. * * Le portail FreePro est une SPA Angular. L'API interne utilisée par Angular * pour charger les données est : GET /api/api_red_october/v1/invoices * Elle retourne un tableau JSON avec les champs : ref, billing_date, total_ht, total_ttc, invoice_status * * Cette API nécessite les cookies de session obtenus après connexion via /account/security/do_login. */ async function fetchInvoiceList(cookies: string): Promise { try { const resp = await fetchWithTimeout(INVOICES_API_URL, { headers: { "Cookie": cookies, "Accept": "application/json", "X-Requested-With": "XMLHttpRequest", "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36", "Referer": BILLING_URL, "Accept-Language": "fr-FR,fr;q=0.9", }, redirect: "follow", }, 15_000); if (!resp.ok) return []; const data = await resp.json(); return parseInvoiceListFromJson(data); } catch { return []; } } /** * Parse la réponse JSON de l'API interne FreePro /api/api_red_october/v1/invoices. * * Structure d'un élément : * { ref: "F202606003800", billing_date: "2026-06-01T00:00:00", total_ht: 1287.37, * total_ttc: 1544.84, invoice_status: "waiting"|"paid", ... } */ function parseInvoiceListFromJson(data: any): FreeproInvoice[] { if (!Array.isArray(data)) return []; const invoices: FreeproInvoice[] = []; for (const item of data) { const invoiceNumber: string = item.ref ?? ""; const billingDate: string = item.billing_date ?? ""; const amount: number = typeof item.total_ttc === "number" ? item.total_ttc : 0; if (!invoiceNumber || !billingDate) continue; // billing_date est au format ISO : "2026-06-01T00:00:00" // On extrait YYYY et MM pour construire le label du mois let month = ""; const dateMatch = billingDate.match(/^(\d{4})-(\d{2})/); if (dateMatch) { month = `${dateMatch[2]}/${dateMatch[1]}`; // "06/2026" } if (month) { invoices.push({ invoiceNumber, date: billingDate, amount, month }); } } // Trier par numéro de facture décroissant (plus récente en premier) invoices.sort((a, b) => b.invoiceNumber.localeCompare(a.invoiceNumber)); return invoices; } /** * Télécharge le CSV d'une facture FreePro */ async function downloadInvoiceCsv(cookies: string, invoiceNumber: string): Promise { try { const csvUrl = `${FREEPRO_BASE_URL}/account/invoice/${invoiceNumber}/primary_csv`; const resp = await fetchWithTimeout(csvUrl, { headers: { "Cookie": cookies, "Accept": "text/csv,application/csv,*/*", "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36", "Referer": BILLING_URL, }, redirect: "follow", }); if (!resp.ok) return null; const arrayBuffer = await resp.arrayBuffer(); return Buffer.from(arrayBuffer); } catch { return null; } } // ── Test de connexion ────────────────────────────────────────────────────── /** * Teste la connexion au portail FreePro avec les credentials fournis. * * Retourne toujours un résultat dans les 20 secondes maximum. */ export async function testFreeproConnection( email: string, password: string ): Promise<{ success: boolean; message: string }> { // Validation basique des entrées if (!email || !email.includes("@")) { return { success: false, message: "Adresse email invalide." }; } if (!password || password.length < 3) { return { success: false, message: "Mot de passe trop court." }; } try { const loginResult = await loginToFreePro(email, password); if (!loginResult.success) { return { success: false, message: loginResult.error ?? "Échec de connexion au portail FreePro.", }; } // Tenter de récupérer la liste des factures pour enrichir le message try { const invoices = await fetchInvoiceList(loginResult.cookies); if (invoices.length > 0) { return { success: true, message: `Connexion réussie. ${invoices.length} facture(s) disponible(s) dans l'espace client.`, }; } } catch { // Pas grave si on ne peut pas lister les factures } return { success: true, message: "Connexion au portail FreePro réussie. Les identifiants sont valides.", }; } catch (err: any) { return { success: false, message: `Erreur inattendue lors du test de connexion : ${err.message}`, }; } } // ── Import automatique ───────────────────────────────────────────────────── /** * Exécute la récupération automatique des factures FreePro pour un utilisateur. */ 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) { result.message = "Connexion réussie mais aucune facture trouvée dans la page /account/billing. Vérifiez que des factures sont disponibles dans votre espace client FreePro."; 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) => { if (existingMonths.has(inv.month)) { result.skipped++; return false; } 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; } const fileName = `Facture_FreePro_${invoice.invoiceNumber}.csv`; const processed = processFreeproExcel(csvBuffer, invoice.month, fileName); // Utiliser le total TTC retourné par l'API FreePro (total_ttc de /api/api_red_october/v1/invoices) // qui correspond au montant affiché sur le portail. Ne pas recalculer depuis les lignes CSV // car la somme ligne par ligne peut différer (remises globales, arrondis, taxes spécifiques). const officialTotalTtc = invoice.amount > 0 ? invoice.amount : processed.totalTtc; await createFreeproImport( { userId, moisLabel: processed.moisLabel, annee: processed.annee, mois: processed.mois, refPiece: processed.refPiece ?? null, fileName, nbLignes: processed.nbLignes, totalTtc: officialTotalTtc.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; } // ── Scheduler en mémoire ─────────────────────────────────────────────────── const activeJobs = new Map(); export function startFreeproAutoJob(userId: number, frequencyMs: number): void { stopFreeproAutoJob(userId); 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`); } 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}`); } } 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; } }