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:
Manus
2026-06-08 09:24:05 -04:00
parent 51830b88be
commit e32d540bc9
10 changed files with 3615 additions and 295 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
CREATE TABLE `freeproSettings` (
`id` int AUTO_INCREMENT NOT NULL,
`userId` int NOT NULL,
`portalUrl` varchar(255) NOT NULL DEFAULT 'https://pro.free.fr',
`loginEmail` varchar(320),
`loginPassword` text,
`frequency` enum('manual','daily','weekly','monthly') NOT NULL DEFAULT 'manual',
`maxAnteriority` int,
`autoEnabled` int NOT NULL DEFAULT 0,
`lastSuccessAt` timestamp,
`lastStatus` text,
`lastImportCount` int DEFAULT 0,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `freeproSettings_id` PRIMARY KEY(`id`),
CONSTRAINT `freeproSettings_userId_unique` UNIQUE(`userId`)
);

File diff suppressed because it is too large Load Diff

View File

@@ -204,6 +204,13 @@
"when": 1780660548610,
"tag": "0028_dusty_kat_farrell",
"breakpoints": true
},
{
"idx": 29,
"version": "5",
"when": 1780924653802,
"tag": "0029_unknown_spot",
"breakpoints": true
}
]
}

View File

@@ -464,3 +464,34 @@ export const freeproVentilationLines = mysqlTable("freeproVentilationLines", {
});
export type FreeproVentilationLine = typeof freeproVentilationLines.$inferSelect;
export type InsertFreeproVentilationLine = typeof freeproVentilationLines.$inferInsert;
/**
* FreePro settings — paramètres de connexion automatique au portail FreePro
* et de récupération périodique des factures CSV
*/
export const freeproSettings = mysqlTable("freeproSettings", {
id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull().unique(), // Un paramétrage par utilisateur
/** URL du portail FreePro (ex: https://pro.free.fr) */
portalUrl: varchar("portalUrl", { length: 255 }).default("https://pro.free.fr").notNull(),
/** Email de connexion au portail FreePro */
loginEmail: varchar("loginEmail", { length: 320 }),
/** Mot de passe de connexion au portail FreePro */
loginPassword: text("loginPassword"),
/** Fréquence de récupération automatique */
frequency: mysqlEnum("frequency", ["manual", "daily", "weekly", "monthly"]).default("manual").notNull(),
/** Date d'antériorité max (timestamp Unix ms) — ne pas récupérer les factures antérieures à cette date */
maxAnteriority: int("maxAnteriority"), // Timestamp Unix en secondes
/** Activation de la récupération automatique */
autoEnabled: int("autoEnabled").default(0).notNull(), // 0 = désactivé, 1 = activé
/** Date de la dernière récupération réussie */
lastSuccessAt: timestamp("lastSuccessAt"),
/** Message de statut de la dernière récupération */
lastStatus: text("lastStatus"),
/** Nombre de factures récupérées lors du dernier run */
lastImportCount: int("lastImportCount").default(0),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type FreeproSettings = typeof freeproSettings.$inferSelect;
export type InsertFreeproSettings = typeof freeproSettings.$inferInsert;

View File

@@ -1015,3 +1015,75 @@ export async function deleteFreeproImport(importId: number): Promise<void> {
await db.delete(freeproVentilationLines).where(eq(freeproVentilationLines.importId, importId));
await db.delete(freeproImports).where(eq(freeproImports.id, importId));
}
// ============= FREEPRO SETTINGS OPERATIONS =============
import {
freeproSettings,
InsertFreeproSettings,
FreeproSettings,
} from "../drizzle/schema";
/** Récupère les paramètres FreePro d'un utilisateur */
export async function getFreeproSettings(userId: number): Promise<FreeproSettings | null> {
const db = await getDb();
if (!db) return null;
const rows = await db
.select()
.from(freeproSettings)
.where(eq(freeproSettings.userId, userId))
.limit(1);
return rows[0] ?? null;
}
/** Crée ou met à jour les paramètres FreePro d'un utilisateur */
export async function upsertFreeproSettings(
userId: number,
data: Partial<Omit<InsertFreeproSettings, "id" | "userId" | "createdAt" | "updatedAt">>
): Promise<void> {
const db = await getDb();
if (!db) return;
const existing = await getFreeproSettings(userId);
if (existing) {
await db
.update(freeproSettings)
.set({ ...data, updatedAt: new Date() })
.where(eq(freeproSettings.userId, userId));
} else {
await db.insert(freeproSettings).values({
userId,
portalUrl: data.portalUrl ?? "https://pro.free.fr",
loginEmail: data.loginEmail ?? null,
loginPassword: data.loginPassword ?? null,
frequency: data.frequency ?? "manual",
maxAnteriority: data.maxAnteriority ?? null,
autoEnabled: data.autoEnabled ?? 0,
lastSuccessAt: data.lastSuccessAt ?? null,
lastStatus: data.lastStatus ?? null,
lastImportCount: data.lastImportCount ?? 0,
});
}
}
/** Met à jour uniquement le statut de la dernière récupération FreePro */
export async function updateFreeproLastRun(
userId: number,
status: string,
importCount: number,
success: boolean
): Promise<void> {
const db = await getDb();
if (!db) return;
const update: Partial<InsertFreeproSettings> = {
lastStatus: status,
lastImportCount: importCount,
updatedAt: new Date(),
};
if (success) {
update.lastSuccessAt = new Date();
}
await db
.update(freeproSettings)
.set(update)
.where(eq(freeproSettings.userId, userId));
}

491
server/freeproAutoImport.ts Normal file
View 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
}
}

View File

@@ -0,0 +1,101 @@
/**
* Tests unitaires pour le module FreePro Settings
* Couvre : helpers DB, service auto-import, procédures tRPC
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// ── Tests helpers freeproAutoImport ────────────────────────────────────────
describe("frequencyToMs", () => {
it("retourne 0 pour manual", async () => {
const { frequencyToMs } = await import("./freeproAutoImport");
expect(frequencyToMs("manual")).toBe(0);
});
it("retourne 24h en ms pour daily", async () => {
const { frequencyToMs } = await import("./freeproAutoImport");
expect(frequencyToMs("daily")).toBe(24 * 60 * 60 * 1000);
});
it("retourne 7j en ms pour weekly", async () => {
const { frequencyToMs } = await import("./freeproAutoImport");
expect(frequencyToMs("weekly")).toBe(7 * 24 * 60 * 60 * 1000);
});
it("retourne 30j en ms pour monthly", async () => {
const { frequencyToMs } = await import("./freeproAutoImport");
expect(frequencyToMs("monthly")).toBe(30 * 24 * 60 * 60 * 1000);
});
it("retourne 0 pour une valeur inconnue", async () => {
const { frequencyToMs } = await import("./freeproAutoImport");
expect(frequencyToMs("unknown")).toBe(0);
});
});
// ── Tests runFreeproAutoImport ─────────────────────────────────────────────
describe("runFreeproAutoImport", () => {
beforeEach(() => {
vi.resetModules();
});
it("retourne un message d'erreur si les paramètres ne sont pas configurés", async () => {
vi.doMock("./db", () => ({
getFreeproSettings: vi.fn().mockResolvedValue(null),
updateFreeproLastRun: vi.fn().mockResolvedValue(undefined),
getFreeproImportsByUser: vi.fn().mockResolvedValue([]),
createFreeproImport: vi.fn().mockResolvedValue(1),
}));
const { runFreeproAutoImport } = await import("./freeproAutoImport");
const result = await runFreeproAutoImport(999);
expect(result.success).toBe(false);
expect(result.message).toContain("Paramètres FreePro non configurés");
});
it("retourne une erreur si les identifiants sont manquants", async () => {
vi.doMock("./db", () => ({
getFreeproSettings: vi.fn().mockResolvedValue({
id: 1,
userId: 1,
portalUrl: "https://pro.free.fr",
loginEmail: null,
loginPassword: null,
frequency: "manual",
maxAnteriority: null,
autoEnabled: 0,
lastSuccessAt: null,
lastStatus: null,
lastImportCount: 0,
createdAt: new Date(),
updatedAt: new Date(),
}),
updateFreeproLastRun: vi.fn().mockResolvedValue(undefined),
getFreeproImportsByUser: vi.fn().mockResolvedValue([]),
createFreeproImport: vi.fn().mockResolvedValue(1),
}));
const { runFreeproAutoImport } = await import("./freeproAutoImport");
const result = await runFreeproAutoImport(1);
expect(result.success).toBe(false);
expect(result.message).toContain("Identifiants FreePro non configurés");
});
});
// ── Tests startFreeproAutoJob / stopFreeproAutoJob ─────────────────────────
describe("startFreeproAutoJob / stopFreeproAutoJob", () => {
it("démarre et arrête un job sans erreur", async () => {
const { startFreeproAutoJob, stopFreeproAutoJob } = await import("./freeproAutoImport");
// Utiliser un intervalle très long pour ne pas déclencher le callback
startFreeproAutoJob(99999, 999999999);
// Arrêter immédiatement
stopFreeproAutoJob(99999);
// Arrêter à nouveau (ne doit pas lever d'erreur)
stopFreeproAutoJob(99999);
});
});

View File

@@ -86,11 +86,14 @@ import { startEmailImportService, stopEmailImportService, isEmailImportServiceRu
import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
import { TRPCError } from "@trpc/server";
import { processFreeproExcel } from "./freeproService";
import { runFreeproAutoImport, testFreeproConnection, startFreeproAutoJob, stopFreeproAutoJob, frequencyToMs } from "./freeproAutoImport";
import {
createFreeproImport,
getFreeproImportsByUser,
getFreeproImportWithLines,
deleteFreeproImport,
getFreeproSettings,
upsertFreeproSettings,
} from "./db";
// Admin-only procedure
@@ -2293,6 +2296,78 @@ export const appRouter = router({
return { base64, fileName };
}),
/** Récupère les paramètres de connexion automatique FreePro */
getSettings: protectedProcedure.query(async ({ ctx }) => {
const s = await getFreeproSettings(ctx.user.id);
// Ne pas exposer le mot de passe en clair
if (s) {
return {
...s,
loginPassword: s.loginPassword ? '••••••••' : null,
hasPassword: !!s.loginPassword,
};
}
return null;
}),
/** Sauvegarde les paramètres de connexion automatique FreePro */
saveSettings: protectedProcedure
.input(
z.object({
portalUrl: z.string().url().optional(),
loginEmail: z.string().email().optional().or(z.literal('')),
loginPassword: z.string().optional(), // vide = ne pas changer
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).optional(),
maxAnteriority: z.number().nullable().optional(), // timestamp Unix en secondes
autoEnabled: z.number().min(0).max(1).optional(),
})
)
.mutation(async ({ input, ctx }) => {
const existing = await getFreeproSettings(ctx.user.id);
const updateData: any = {};
if (input.portalUrl !== undefined) updateData.portalUrl = input.portalUrl;
if (input.loginEmail !== undefined) updateData.loginEmail = input.loginEmail || null;
// Ne mettre à jour le mot de passe que si une vraie valeur est fournie
if (input.loginPassword && input.loginPassword !== '••••••••') {
updateData.loginPassword = input.loginPassword;
}
if (input.frequency !== undefined) updateData.frequency = input.frequency;
if (input.maxAnteriority !== undefined) updateData.maxAnteriority = input.maxAnteriority;
if (input.autoEnabled !== undefined) updateData.autoEnabled = input.autoEnabled;
await upsertFreeproSettings(ctx.user.id, updateData);
// Gérer le job périodique
const newSettings = await getFreeproSettings(ctx.user.id);
if (newSettings?.autoEnabled && newSettings.frequency !== 'manual') {
const ms = frequencyToMs(newSettings.frequency);
if (ms > 0) startFreeproAutoJob(ctx.user.id, ms);
} else {
stopFreeproAutoJob(ctx.user.id);
}
return { success: true };
}),
/** Teste la connexion au portail FreePro */
testConnection: protectedProcedure
.input(
z.object({
email: z.string().email(),
password: z.string().min(1),
})
)
.mutation(async ({ input }) => {
return testFreeproConnection(input.email, input.password);
}),
/** Force la récupération immédiate des factures FreePro */
forceImport: protectedProcedure.mutation(async ({ ctx }) => {
const result = await runFreeproAutoImport(ctx.user.id);
return result;
}),
/** Exporte la ventilation FreePro vers SharePoint */
exportToSharePoint: protectedProcedure
.input(z.object({ id: z.number(), pdfBase64: z.string().optional() }))

10
todo.md
View File

@@ -657,3 +657,13 @@
- [x] Menu "Ventilations > FreePro" ajouté dans DashboardLayout
- [x] Route /ventilation-freepro ajoutée dans App.tsx
- [x] Déploiement sur recette (git pull + migrations DB + docker compose up --build)
## Module Ventilation FreePro - Onglet Paramétrage (connexion automatique web FreePro)
- [x] Schéma DB : ajouter table `freeproSettings` (URL portail, login, password, fréquence, date antériorité, dernière récupération)
- [x] Migration DB : pnpm db:push
- [x] Helper DB : getFreeproSettings, upsertFreeproSettings
- [x] Service freeproAutoImport.ts : connexion portail FreePro web, téléchargement CSV, pipeline processFreeproExcel + createFreeproImport
- [x] Procédures tRPC : freepro.getSettings, freepro.saveSettings, freepro.forceImport
- [x] Job cron WebDev : vérification périodique selon fréquence configurée (setInterval en mémoire)
- [x] Frontend VentilationFreePro.tsx : wrapper Tabs (onglet 1 = import manuel, onglet 2 = paramétrage)
- [x] Onglet Paramétrage : formulaire credentials FreePro web, fréquence, date antériorité, bouton "Forcer récupération", statut dernière récupération