Checkpoint: Ajout de l'onglet "Paramétrage" dans la page Ventilation FreePro :
- Table DB freeproSettings (URL portail, credentials, fréquence, date antériorité, statut dernière récupération) - Service freeproAutoImport.ts : connexion HTTP au portail FreePro, téléchargement CSV, pipeline d'import - Procédures tRPC : getSettings, saveSettings, testConnection, forceImport - Job périodique en mémoire (daily/weekly/monthly) via setInterval - Frontend : wrapper Tabs (onglet 1 = Import & Historique, onglet 2 = Paramétrage) - Onglet Paramétrage : credentials, fréquence, date antériorité, bouton "Forcer récupération", statut - Tests unitaires : 8 tests passés
This commit is contained in:
491
server/freeproAutoImport.ts
Normal file
491
server/freeproAutoImport.ts
Normal file
@@ -0,0 +1,491 @@
|
||||
/**
|
||||
* 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<FreeproInvoice[]> {
|
||||
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<Buffer | null> {
|
||||
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<AutoImportResult> {
|
||||
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<number, NodeJS.Timeout>();
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user