Checkpoint: Fenêtre administrateur Salaires finalisée : import multipart limité, chiffrement AES-256-GCM avant stockage persistant hors conteneur, métadonnées/index métier minimisés en base, consultation PDF déchiffrée côté serveur avec no-store, filtres année/mois et comparaison M-1 expliquée. Réindexation sûre des archives partielles sans écrasement du PDF. Validation réelle sur deux liasses mensuelles : index partiel signalé sans invention de données, comparaison affichée et lecture après redémarrage. 55 tests Vitest, TypeScript et build de production validés.
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 1m54s

This commit is contained in:
Manus
2026-08-29 22:40:59 +00:00
parent 8a58f2c2bd
commit 82de3181df
29 changed files with 6179 additions and 1 deletions

View File

@@ -6,6 +6,7 @@ import { createExpressMiddleware } from "@trpc/server/adapters/express";
import { registerOAuthRoutes } from "./oauth";
import { registerStorageProxy } from "./storageProxy";
import { appRouter } from "../routers";
import { registerSalairesHttpRoutes } from "../salairesHttp";
import { createContext } from "./context";
import { serveStatic, setupVite } from "./vite";
@@ -36,6 +37,7 @@ async function startServer() {
app.use(express.urlencoded({ limit: "50mb", extended: true }));
registerStorageProxy(app);
registerOAuthRoutes(app);
registerSalairesHttpRoutes(app);
// tRPC API
app.use(
"/api/trpc",

View File

@@ -74,6 +74,7 @@ vi.mock("./db", async (importOriginal) => {
getMasseSalariale: vi.fn(),
importMasseSalariale: vi.fn(),
setMasseSalarialeEvolution: vi.fn(),
getSalairesMensuels: vi.fn(),
};
});
@@ -434,3 +435,72 @@ describe("masseSalariale", () => {
expect(db.setMasseSalarialeEvolution).not.toHaveBeenCalled();
});
});
// ─── Tests Salaires : sonde de chiffrement et confidentialité ─────────────────
describe("salaires.getSecurityStatus", () => {
it("valide la clé de chiffrement configurée via une procédure administrateur", async () => {
const { ctx } = createAuthCtx({ role: "admin" });
await expect(appRouter.createCaller(ctx).salaires.getSecurityStatus())
.resolves.toEqual({ ready: true, algorithm: "AES-256-GCM" });
});
it("interdit la sonde de sécurité aux profils non administrateurs", async () => {
const { ctx } = createAuthCtx({ role: "standard" });
await expect(appRouter.createCaller(ctx).salaires.getSecurityStatus()).rejects.toThrow();
});
});
describe("salaires.list", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("retourne l'index métier et explique la variation uniquement à l'administrateur", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
const mayLiasse = {
id: 9, annee: 2026, mois: 5, nomFichier: "mai.pdf", stockageKey: "secret-key",
empreinteSha256: "a".repeat(64), tailleOctets: 1200, ivBase64: "iv", authTagBase64: "tag",
versionCle: 1, statutExtraction: "ready", erreurExtraction: null, importePar: 1, importeLe: new Date(),
};
const juneLiasse = { ...mayLiasse, id: 10, mois: 6, nomFichier: "juin.pdf", stockageKey: "secret-key-2" };
const mayBulletin = {
id: 1, liasseId: 9, matricule: "000001", nom: "DUPONT", prenom: "TEST", poste: "Responsable",
brutMensuelCents: 300000, brutAvecPrimesCents: 320000, primeAstreinteCents: 20000,
explicationEcart: null, numeroPage: 1, createdAt: new Date(), updatedAt: new Date(),
};
const juneBulletin = { ...mayBulletin, id: 2, liasseId: 10, brutAvecPrimesCents: 350000, primeAstreinteCents: 40000 };
(db.getSalairesMensuels as ReturnType<typeof vi.fn>).mockResolvedValue({
liasses: [juneLiasse, mayLiasse],
rows: [{ liasse: juneLiasse, bulletin: juneBulletin }, { liasse: mayLiasse, bulletin: mayBulletin }],
});
const { ctx } = createAuthCtx({ role: "admin" });
const result = await appRouter.createCaller(ctx).salaires.list({ annee: 2026, mois: 6 });
expect(result.indicateurs).toEqual({ nombreSalaries: 1, totalBrutCents: 350000, totalEcartCents: 30000 });
expect(result.bulletins[0]).toMatchObject({ ecartBrutCents: 30000, brutPrecedentCents: 320000 });
expect(result.bulletins[0]?.explication).toContain("astreintes");
expect(JSON.stringify(result)).not.toContain("secret-key");
expect(JSON.stringify(result)).not.toContain("empreinteSha256");
expect(result.liasses[0]?.pdfPath).toBe("/api/salaires/liasses/10/pdf");
});
it("interdit l'index salarial aux profils standard et lecture seule", async () => {
const standard = createAuthCtx({ role: "standard" });
const readonly = createAuthCtx({ role: "readonly" });
await expect(appRouter.createCaller(standard.ctx).salaires.list({})).rejects.toThrow();
await expect(appRouter.createCaller(readonly.ctx).salaires.list({})).rejects.toThrow();
});
it("rejette un mois invalide avant tout accès aux données", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
const { ctx } = createAuthCtx({ role: "admin" });
await expect(appRouter.createCaller(ctx).salaires.list({ mois: 13 })).rejects.toThrow();
expect(db.getSalairesMensuels).not.toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,4 @@
import { and, asc, eq } from "drizzle-orm";
import { and, asc, desc, eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import {
capexLignes,
@@ -21,6 +21,8 @@ import {
opexPostes,
opexValidated,
parametresApp,
salairesBulletins,
salairesLiasses,
users,
} from "../drizzle/schema";
import { isOpexEtablissementCode } from "../shared/opexValidation";
@@ -559,3 +561,151 @@ export async function setMasseSalarialeEvolution(
},
});
}
// ─────────────────────────────────────────────────────────────────────────────
// SALAIRES MENSUELS — Métadonnées de liasses et index de bulletins chiffrés
// ─────────────────────────────────────────────────────────────────────────────
export type SalaireBulletinImport = {
matricule: string;
nom: string;
prenom: string;
poste: string;
brutMensuelCents: number;
brutAvecPrimesCents: number;
primeAstreinteCents: number;
explicationEcart?: string | null;
numeroPage?: number | null;
};
export type SalaireLiasseCreateInput = {
annee: number;
mois: number;
nomFichier: string;
stockageKey: string;
empreinteSha256: string;
tailleOctets: number;
ivBase64: string;
authTagBase64: string;
importePar: number;
statutExtraction: "ready" | "partiel" | "a_controler";
erreurExtraction?: string | null;
bulletins: SalaireBulletinImport[];
};
/** Retourne une liasse de période pour empêcher une importation mensuelle multiple. */
export async function getSalaireLiasseByPeriod(annee: number, mois: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const [liasse] = await db
.select()
.from(salairesLiasses)
.where(and(eq(salairesLiasses.annee, annee), eq(salairesLiasses.mois, mois)));
return liasse ?? null;
}
/** Retourne une liasse par identifiant, sans jamais renvoyer de fichier déchiffré. */
export async function getSalaireLiasseById(id: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const [liasse] = await db.select().from(salairesLiasses).where(eq(salairesLiasses.id, id));
return liasse ?? null;
}
/**
* Persiste de façon atomique la référence chiffrée de la liasse et son index
* métier. Les octets du PDF restent exclusivement dans le stockage persistant.
*/
export async function createSalaireLiasse(input: SalaireLiasseCreateInput) {
const db = await getDb();
if (!db) throw new Error("Database not available");
return db.transaction(async (tx) => {
const [existing] = await tx
.select({ id: salairesLiasses.id })
.from(salairesLiasses)
.where(and(eq(salairesLiasses.annee, input.annee), eq(salairesLiasses.mois, input.mois)));
if (existing) throw new Error("Une liasse est déjà enregistrée pour cette période");
await tx.insert(salairesLiasses).values({
annee: input.annee,
mois: input.mois,
nomFichier: input.nomFichier,
stockageKey: input.stockageKey,
empreinteSha256: input.empreinteSha256,
tailleOctets: input.tailleOctets,
ivBase64: input.ivBase64,
authTagBase64: input.authTagBase64,
importePar: input.importePar,
statutExtraction: input.statutExtraction,
erreurExtraction: input.erreurExtraction ?? null,
});
const [liasse] = await tx
.select({ id: salairesLiasses.id })
.from(salairesLiasses)
.where(eq(salairesLiasses.stockageKey, input.stockageKey));
if (!liasse) throw new Error("Liasse non retrouvée après enregistrement");
if (input.bulletins.length > 0) {
await tx.insert(salairesBulletins).values(input.bulletins.map((bulletin) => ({
...bulletin,
liasseId: liasse.id,
})));
}
return { liasseId: liasse.id, bulletins: input.bulletins.length };
});
}
/**
* Met à jour l'index métier d'une liasse déjà archivée sans modifier sa clé de
* stockage, son empreinte ou ses paramètres de chiffrement.
*/
export async function replaceSalaireLiasseIndex(
liasseId: number,
input: Pick<SalaireLiasseCreateInput, "bulletins" | "statutExtraction" | "erreurExtraction">,
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
return db.transaction(async (tx) => {
await tx.delete(salairesBulletins).where(eq(salairesBulletins.liasseId, liasseId));
await tx.update(salairesLiasses).set({
statutExtraction: input.statutExtraction,
erreurExtraction: input.erreurExtraction ?? null,
}).where(eq(salairesLiasses.id, liasseId));
if (input.bulletins.length > 0) {
await tx.insert(salairesBulletins).values(input.bulletins.map((bulletin) => ({ ...bulletin, liasseId })));
}
return { bulletins: input.bulletins.length };
});
}
/**
* Liste les liasses filtrées et les bulletins associés. La comparaison est
* réalisée dans le routeur à partir de ces montants normalisés en centimes.
*/
export async function getSalairesMensuels(annee?: number, mois?: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const filter = annee && mois
? and(eq(salairesLiasses.annee, annee), eq(salairesLiasses.mois, mois))
: annee
? eq(salairesLiasses.annee, annee)
: mois
? eq(salairesLiasses.mois, mois)
: undefined;
const liassesQuery = db.select().from(salairesLiasses);
const liasses = filter
? await liassesQuery.where(filter).orderBy(desc(salairesLiasses.annee), desc(salairesLiasses.mois))
: await liassesQuery.orderBy(desc(salairesLiasses.annee), desc(salairesLiasses.mois));
const bulletinsQuery = db
.select({ liasse: salairesLiasses, bulletin: salairesBulletins })
.from(salairesBulletins)
.innerJoin(salairesLiasses, eq(salairesBulletins.liasseId, salairesLiasses.id));
const rows = filter
? await bulletinsQuery.where(filter).orderBy(asc(salairesBulletins.nom), asc(salairesBulletins.prenom))
: await bulletinsQuery.orderBy(desc(salairesLiasses.annee), desc(salairesLiasses.mois), asc(salairesBulletins.nom));
return { liasses, rows };
}

View File

@@ -0,0 +1,37 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { decryptPayrollPdf, encryptPayrollPdf, getPayrollEncryptionStatus } from "./payrollCrypto";
const originalKey = process.env.PAYROLL_ENCRYPTION_KEY;
describe.sequential("payrollCrypto", () => {
beforeEach(() => {
process.env.PAYROLL_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
});
afterEach(() => {
if (originalKey === undefined) delete process.env.PAYROLL_ENCRYPTION_KEY;
else process.env.PAYROLL_ENCRYPTION_KEY = originalKey;
});
it("chiffre puis déchiffre un PDF sans changer son contenu", () => {
const original = Buffer.from("%PDF-1.7 contenu de test", "utf8");
const encrypted = encryptPayrollPdf(original);
expect(encrypted.ciphertext).not.toEqual(original);
expect(decryptPayrollPdf(encrypted)).toEqual(original);
expect(getPayrollEncryptionStatus()).toEqual({ ready: true, algorithm: "AES-256-GCM" });
});
it("refuse un tag d'authentification altéré", () => {
const encrypted = encryptPayrollPdf(Buffer.from("%PDF-1.7 contenu de test", "utf8"));
const tamperedTag = Buffer.from(encrypted.authTagBase64, "base64");
tamperedTag[0] = tamperedTag[0]! ^ 0xff;
expect(() => decryptPayrollPdf({ ...encrypted, authTagBase64: tamperedTag.toString("base64") })).toThrow();
});
it("ne révèle aucune erreur détaillée lorsque la clé est invalide", () => {
process.env.PAYROLL_ENCRYPTION_KEY = "invalide";
expect(getPayrollEncryptionStatus()).toEqual({ ready: false, algorithm: "AES-256-GCM" });
});
});

62
server/payrollCrypto.ts Normal file
View File

@@ -0,0 +1,62 @@
import crypto from "node:crypto";
const ALGORITHM = "aes-256-gcm";
const IV_BYTES = 12;
const AUTH_TAG_BYTES = 16;
export type EncryptedPayrollFile = {
ciphertext: Buffer;
ivBase64: string;
authTagBase64: string;
};
/**
* Lit la clé dédiée aux bulletins. La clé ne transite jamais vers le client et
* doit être une valeur Base64 représentant précisément 32 octets AES-256.
*/
function getPayrollEncryptionKey(): Buffer {
const encodedKey = process.env.PAYROLL_ENCRYPTION_KEY?.trim();
if (!encodedKey) throw new Error("Clé de chiffrement des bulletins absente");
const key = Buffer.from(encodedKey, "base64");
if (key.length !== 32) {
throw new Error("La clé de chiffrement des bulletins doit contenir 32 octets");
}
return key;
}
/** Sondage sans divulgation de la clé, destiné aux contrôles administrateur. */
export function getPayrollEncryptionStatus() {
try {
getPayrollEncryptionKey();
return { ready: true as const, algorithm: "AES-256-GCM" };
} catch {
return { ready: false as const, algorithm: "AES-256-GCM" };
}
}
/** Chiffre un PDF avant son envoi vers le stockage persistant. */
export function encryptPayrollPdf(plaintext: Buffer): EncryptedPayrollFile {
const key = getPayrollEncryptionKey();
const iv = crypto.randomBytes(IV_BYTES);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_BYTES });
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
return {
ciphertext,
ivBase64: iv.toString("base64"),
authTagBase64: cipher.getAuthTag().toString("base64"),
};
}
/** Déchiffre le document uniquement côté serveur, après contrôle des droits. */
export function decryptPayrollPdf(encrypted: EncryptedPayrollFile): Buffer {
const key = getPayrollEncryptionKey();
const iv = Buffer.from(encrypted.ivBase64, "base64");
const authTag = Buffer.from(encrypted.authTagBase64, "base64");
if (iv.length !== IV_BYTES || authTag.length !== AUTH_TAG_BYTES) {
throw new Error("Paramètres de chiffrement de bulletin invalides");
}
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_BYTES });
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(encrypted.ciphertext), decipher.final()]);
}

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { extractPayrollBulletins } from "./payrollParser";
const SAMPLE_BULLETIN = `
##BULLETIN##05-2026##009999##DUPONT##TEST##
Matricule : 009999
Emploi : RESPONSABLE APPLICATIF Monsieur TEST DUPONT
Salaire de base (Forfait 218 jours) 3 650.00
Prime d'astreinte 200.00
Prime d'astreinte 200.00
Prime d'activité 150.00
Salaire brut 4 200.00
N° SS : ne-pas-extraire
Iban / Rib : ne-pas-extraire
Net à payer avant impôt sur le revenu 2 000.00
`;
describe("extractPayrollBulletins", () => {
it("n'extrait que les champs métier autorisés et additionne les astreintes", () => {
const result = extractPayrollBulletins(SAMPLE_BULLETIN);
expect(result).toMatchObject({ statutExtraction: "ready", erreurExtraction: null });
expect(result.bulletins).toEqual([
expect.objectContaining({
matricule: "009999",
nom: "DUPONT",
prenom: "TEST",
poste: "RESPONSABLE APPLICATIF",
brutMensuelCents: 365000,
brutAvecPrimesCents: 420000,
primeAstreinteCents: 40000,
}),
]);
expect(Object.keys(result.bulletins[0]!)).not.toContain("iban");
expect(JSON.stringify(result)).not.toContain("ne-pas-extraire");
});
it("signale une liasse non indexable sans prétendre perdre le PDF", () => {
expect(extractPayrollBulletins("contenu PDF non reconnu")).toEqual({
bulletins: [],
statutExtraction: "a_controler",
erreurExtraction: "Aucun bulletin na pu être indexé automatiquement. Le PDF reste conservé et consultable.",
});
});
it("indexe chaque salarié lorsque le texte PDF concatène les pages sans séparateur", () => {
const secondBulletin = SAMPLE_BULLETIN
.replace("##009999##DUPONT##TEST##", "##008888##MARTIN##EXEMPLE##")
.replaceAll("009999", "008888")
.replaceAll("TEST DUPONT", "EXEMPLE MARTIN");
const result = extractPayrollBulletins(`${SAMPLE_BULLETIN}\n${secondBulletin}`);
expect(result.bulletins).toHaveLength(2);
expect(result.bulletins.map((bulletin) => bulletin.matricule)).toEqual(["009999", "008888"]);
});
it("accepte un emploi suivi de la civilité par un seul espace, comme certains PDF extraits", () => {
const compacted = SAMPLE_BULLETIN.replace(" Monsieur", " Monsieur");
expect(extractPayrollBulletins(compacted).bulletins).toHaveLength(1);
});
it("lit un poste sur sa seule ligne lorsque la civilité est rendue à la ligne suivante", () => {
const separateCivilite = SAMPLE_BULLETIN.replace(" Monsieur TEST DUPONT", "\nMonsieur TEST DUPONT");
expect(extractPayrollBulletins(separateCivilite).bulletins).toHaveLength(1);
});
it("refuse une extraction d'emploi répartie sur plusieurs lignes pour ne jamais persister de donnée personnelle", () => {
const unsafeLayout = SAMPLE_BULLETIN.replace(
/Emploi\s*:[^\n]*/,
"Emploi :\nAdresse personnelle à ignorer\nRESPONSABLE APPLICATIF\nMonsieur TEST DUPONT",
);
expect(extractPayrollBulletins(unsafeLayout)).toMatchObject({
bulletins: [],
statutExtraction: "a_controler",
});
});
});

151
server/payrollParser.ts Normal file
View File

@@ -0,0 +1,151 @@
/**
* Extraction volontairement minimisée des liasses de paie. Aucune coordonnée,
* donnée bancaire, numéro de sécurité sociale ou rémunération nette n'est
* renvoyé ni persisté par ce module.
*/
export type ParsedPayrollBulletin = {
matricule: string;
nom: string;
prenom: string;
poste: string;
brutMensuelCents: number;
brutAvecPrimesCents: number;
primeAstreinteCents: number;
explicationEcart: string | null;
numeroPage: number;
};
export type PayrollExtractionResult = {
bulletins: ParsedPayrollBulletin[];
statutExtraction: "ready" | "partiel" | "a_controler";
erreurExtraction: string | null;
};
const MONEY_RE = /(?<![\d.])-?\s*\d{1,3}(?:[ \u00a0]\d{3})*\.\d{2}(?!\d)/g;
const HEADER_RE = /##BULLETIN##\d{2}-\d{4}##(\d+)##([^#]+)##([^#]+)##/g;
function amountsIn(line: string): number[] {
return (line.match(MONEY_RE) ?? []).map((raw) => Math.round(Number(raw.replace(/[ \u00a0]/g, "")) * 100));
}
function firstMatchingLine(lines: string[], prefix: string): string | null {
return lines.find((line) => line.trim().toLocaleLowerCase("fr-FR").startsWith(prefix)) ?? null;
}
function pagesForHeaders(text: string): Array<{ content: string; page: number }> {
const pages = text.split("\f");
const grouped: Array<{ content: string; page: number; matricule: string }> = [];
let current: { content: string; page: number; matricule: string } | null = null;
pages.forEach((page, index) => {
HEADER_RE.lastIndex = 0;
const headers: Array<{ index: number; length: number; matricule: string }> = [];
let header: RegExpExecArray | null;
while ((header = HEADER_RE.exec(page)) !== null) {
headers.push({ index: header.index, length: header[0].length, matricule: header[1] });
}
if (headers.length === 0 && current) {
current.content += `\n${page}`;
return;
}
if (headers.length === 0) return;
headers.forEach((entry, headerIndex) => {
const next = headers[headerIndex + 1];
const content = page.slice(entry.index, next?.index);
if (headerIndex === 0 && current?.matricule === entry.matricule) {
current.content += `\n${content}`;
} else {
current = { content, page: index + 1, matricule: entry.matricule };
grouped.push(current);
}
});
});
// Certains PDF ne contiennent pas de saut de page exploitable. On tente alors
// une extraction complète, qui sera marquée "à contrôler" sans en-tête fiable.
return grouped.length > 0 ? grouped.map(({ content, page }) => ({ content, page })) : [{ content: text, page: 1 }];
}
function getIdentity(text: string): { matricule: string; nom: string; prenom: string; poste: string } | null {
HEADER_RE.lastIndex = 0;
const header = HEADER_RE.exec(text);
const lines = text.split(/\r?\n/);
const matricule = /Matricule\s*:\s*(?:M|N|N°)?\s*(\d+)/.exec(text)?.[1] ?? header?.[1];
const emploiLine = lines.find((line) => /Emploi\s*:/i.test(line));
const poste = /Emploi\s*:\s*(.*)$/i.exec(emploiLine ?? "")?.[1]
?.replace(/\s+(?:Monsieur|Madame)\b.*$/i, "")
.trim();
if (!matricule || !poste) return null;
if (header?.[2] && header[3]) {
return {
matricule,
nom: header[2].trim(),
prenom: header[3].trim(),
poste,
};
}
const displayName = /Emploi\s*:[\s\S]*?(?:Monsieur|Madame)\s+([A-ZÀ-ÖØ-Ý' -]+)/.exec(text)?.[1]?.trim();
const names = displayName?.split(/\s+/).filter(Boolean) ?? [];
if (names.length < 2) return null;
return { matricule, prenom: names[0], nom: names.slice(1).join(" "), poste };
}
function parseOneBulletin(content: string, page: number): ParsedPayrollBulletin | null {
const lines = content.split(/\r?\n/).map((line) => line.trimEnd());
const identity = getIdentity(content);
const baseAmounts = amountsIn(firstMatchingLine(lines, "salaire de base") ?? "");
const grossAmounts = amountsIn(firstMatchingLine(lines, "salaire brut") ?? "");
if (!identity || baseAmounts.length === 0 || grossAmounts.length === 0) return null;
const primeAstreinteCents = lines
.filter((line) => line.trim().toLocaleLowerCase("fr-FR").startsWith("prime d'astreinte"))
.map((line) => amountsIn(line).at(-1) ?? 0)
.reduce((sum, amount) => sum + amount, 0);
const brutMensuelCents = baseAmounts.at(-1)!;
const brutAvecPrimesCents = grossAmounts.at(-1)!;
const otherVariableCents = brutAvecPrimesCents - brutMensuelCents - primeAstreinteCents;
const parts: string[] = [];
if (primeAstreinteCents !== 0) parts.push("prime dastreinte relevée");
if (otherVariableCents !== 0) parts.push("autres éléments de rémunération inclus dans le brut");
return {
...identity,
brutMensuelCents,
brutAvecPrimesCents,
primeAstreinteCents,
explicationEcart: parts.length ? parts.join(" ; ") : null,
numeroPage: page,
};
}
/**
* Transforme le texte d'une liasse PDF en index métier restreint. Toute absence
* de champ indispensable est signalée sans empêcher la conservation du PDF.
*/
export function extractPayrollBulletins(text: string): PayrollExtractionResult {
const candidates = pagesForHeaders(text);
const bulletins = candidates
.map(({ content, page }) => parseOneBulletin(content, page))
.filter((bulletin): bulletin is ParsedPayrollBulletin => bulletin !== null);
if (bulletins.length === 0) {
return {
bulletins: [],
statutExtraction: "a_controler",
erreurExtraction: "Aucun bulletin na pu être indexé automatiquement. Le PDF reste conservé et consultable.",
};
}
if (bulletins.length !== candidates.length) {
return {
bulletins,
statutExtraction: "partiel",
erreurExtraction: "Une ou plusieurs fiches nont pas pu être indexées automatiquement. Le PDF reste consultable.",
};
}
return { bulletins, statutExtraction: "ready", erreurExtraction: null };
}

39
server/payrollPdfText.ts Normal file
View File

@@ -0,0 +1,39 @@
import pdfParse from "pdf-parse/lib/pdf-parse.js";
type PdfTextItem = { str?: string; transform?: number[] };
type PdfPageData = { getTextContent: (options: { normalizeWhitespace: boolean; disableCombineTextItems: boolean }) => Promise<{ items: PdfTextItem[] }> };
/**
* Reconstruit les lignes en ordonnant les fragments de texte par coordonnées.
* Cette étape évite que les valeurs de colonnes distinctes (adresse, IBAN, etc.)
* soient concaténées dans l'intitulé de poste par l'extracteur PDF standard.
*/
export async function renderPayrollPage(pageData: unknown): Promise<string> {
const pdfPage = pageData as PdfPageData;
const textContent = await pdfPage.getTextContent({ normalizeWhitespace: false, disableCombineTextItems: false });
const fragments = textContent.items
.filter((item) => item.str && item.transform && item.transform.length >= 6)
.map((item) => ({ text: item.str!.trim(), x: item.transform![4]!, y: item.transform![5]! }))
.filter((item) => item.text.length > 0)
.sort((a, b) => Math.abs(b.y - a.y) > 0.75 ? b.y - a.y : a.x - b.x);
const lines: Array<{ y: number; items: Array<{ text: string; x: number }> }> = [];
fragments.forEach((fragment) => {
const previous = lines[lines.length - 1];
if (previous && Math.abs(previous.y - fragment.y) <= 0.75) {
previous.items.push({ text: fragment.text, x: fragment.x });
} else {
lines.push({ y: fragment.y, items: [{ text: fragment.text, x: fragment.x }] });
}
});
return `${lines
.map((line) => line.items.sort((a, b) => a.x - b.x).map((item) => item.text).join(" "))
.join("\n")}\f`;
}
/** Extrait le texte d'une liasse en préservant les limites de pages. */
export async function extractPayrollPdfText(pdf: Buffer): Promise<string> {
const parsed = await pdfParse(pdf, { pagerender: renderPayrollPage });
return parsed.text;
}

View File

@@ -8,6 +8,7 @@ import { sdk } from "./_core/sdk";
import { systemRouter } from "./_core/systemRouter";
import { adminProcedure, protectedProcedure, publicProcedure, router } from "./_core/trpc";
import { isOpexEtablissementCode, normalizeOpexEtablissementCode } from "../shared/opexValidation";
import { getPayrollEncryptionStatus } from "./payrollCrypto";
const writeProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.user.role === "readonly") {
@@ -22,6 +23,35 @@ const opexEtablissementCodeSchema = z
.transform(normalizeOpexEtablissementCode)
.refine(isOpexEtablissementCode, { message: "Code établissement OPEX invalide" });
const salaireFiltersSchema = z.object({
annee: z.number().int().min(2020).max(2100).optional(),
mois: z.number().int().min(1).max(12).optional(),
});
function periodKey(annee: number, mois: number) {
return annee * 12 + (mois - 1);
}
function describeMonthlySalaryDifference(
current: { brutMensuelCents: number; brutAvecPrimesCents: number; primeAstreinteCents: number },
previous: { brutMensuelCents: number; brutAvecPrimesCents: number; primeAstreinteCents: number } | undefined,
) {
if (!previous) return "Aucun bulletin de ce salarié disponible au mois précédent.";
const difference = current.brutAvecPrimesCents - previous.brutAvecPrimesCents;
if (difference === 0) return "Brut stable par rapport au mois précédent.";
const sign = (amount: number) => amount > 0 ? "hausse" : "baisse";
const centsLabel = (amount: number) => `${(Math.abs(amount) / 100).toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`;
const reasons: string[] = [];
const baseDifference = current.brutMensuelCents - previous.brutMensuelCents;
const astreinteDifference = current.primeAstreinteCents - previous.primeAstreinteCents;
const otherDifference = difference - baseDifference - astreinteDifference;
if (baseDifference !== 0) reasons.push(`${sign(baseDifference)} de la base (${centsLabel(baseDifference)})`);
if (astreinteDifference !== 0) reasons.push(`${sign(astreinteDifference)} des astreintes (${centsLabel(astreinteDifference)})`);
if (otherDifference !== 0) reasons.push(`${sign(otherDifference)} d'autres éléments inclus au brut (${centsLabel(otherDifference)})`);
return reasons.length > 0 ? reasons.join(" ; ") : "Variation du brut à contrôler.";
}
export const appRouter = router({
system: systemRouter,
@@ -248,6 +278,88 @@ export const appRouter = router({
}),
}),
/** Informations de disponibilité du stockage salarial, sans divulgation de secret. */
salaires: router({
getSecurityStatus: adminProcedure.query(() => getPayrollEncryptionStatus()),
periods: adminProcedure.query(async () => {
const { liasses } = await db.getSalairesMensuels();
return liasses.map((liasse) => ({
id: liasse.id,
annee: liasse.annee,
mois: liasse.mois,
nomFichier: liasse.nomFichier,
tailleOctets: liasse.tailleOctets,
statutExtraction: liasse.statutExtraction,
erreurExtraction: liasse.erreurExtraction,
importeLe: liasse.importeLe,
}));
}),
/**
* Renvoie exclusivement l'index métier des bulletins, jamais les références
* de stockage, empreintes ou paramètres cryptographiques.
*/
list: adminProcedure.input(salaireFiltersSchema).query(async ({ input }) => {
const { liasses, rows } = await db.getSalairesMensuels();
const liassesByPeriod = new Map(liasses.map((liasse) => [periodKey(liasse.annee, liasse.mois), liasse]));
const rowsByLiasse = new Map<number, Map<string, typeof rows[number]["bulletin"]>>();
rows.forEach(({ liasse, bulletin }) => {
const byEmployee = rowsByLiasse.get(liasse.id) ?? new Map();
byEmployee.set(bulletin.matricule, bulletin);
rowsByLiasse.set(liasse.id, byEmployee);
});
const selectedLiasses = liasses.filter((liasse) =>
(!input.annee || liasse.annee === input.annee) && (!input.mois || liasse.mois === input.mois),
);
const selectedLiasseIds = new Set(selectedLiasses.map((liasse) => liasse.id));
const bulletins = rows
.filter(({ liasse }) => selectedLiasseIds.has(liasse.id))
.map(({ liasse, bulletin }) => {
const previousLiasse = liassesByPeriod.get(periodKey(liasse.annee, liasse.mois) - 1);
const previous = previousLiasse ? rowsByLiasse.get(previousLiasse.id)?.get(bulletin.matricule) : undefined;
const ecartBrutCents = previous ? bulletin.brutAvecPrimesCents - previous.brutAvecPrimesCents : null;
return {
liasseId: liasse.id,
annee: liasse.annee,
mois: liasse.mois,
matricule: bulletin.matricule,
nom: bulletin.nom,
prenom: bulletin.prenom,
poste: bulletin.poste,
brutMensuelCents: bulletin.brutMensuelCents,
brutAvecPrimesCents: bulletin.brutAvecPrimesCents,
primeAstreinteCents: bulletin.primeAstreinteCents,
brutPrecedentCents: previous?.brutAvecPrimesCents ?? null,
ecartBrutCents,
ecartBrutPourcentage: previous && previous.brutAvecPrimesCents !== 0
? Math.round((ecartBrutCents! / previous.brutAvecPrimesCents) * 10_000) / 100
: null,
explication: describeMonthlySalaryDifference(bulletin, previous),
numeroPage: bulletin.numeroPage,
};
});
const totalBrutCents = bulletins.reduce((total, bulletin) => total + bulletin.brutAvecPrimesCents, 0);
const totalEcartCents = bulletins.reduce((total, bulletin) => total + (bulletin.ecartBrutCents ?? 0), 0);
return {
liasses: selectedLiasses.map((liasse) => ({
id: liasse.id,
annee: liasse.annee,
mois: liasse.mois,
nomFichier: liasse.nomFichier,
tailleOctets: liasse.tailleOctets,
statutExtraction: liasse.statutExtraction,
erreurExtraction: liasse.erreurExtraction,
importeLe: liasse.importeLe,
pdfPath: `/api/salaires/liasses/${liasse.id}/pdf`,
})),
bulletins,
indicateurs: { totalBrutCents, totalEcartCents, nombreSalaries: bulletins.length },
};
}),
}),
// Les données de rémunération sont sensibles : aucune procédure de ce
// sous-routeur n'est accessible aux profils standard ou lecture seule.
masseSalariale: router({

118
server/salairesHttp.test.ts Normal file
View File

@@ -0,0 +1,118 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./_core/sdk", () => ({ sdk: { authenticateRequest: vi.fn() } }));
vi.mock("./db", () => ({
getSalaireLiasseByPeriod: vi.fn(),
createSalaireLiasse: vi.fn(),
getSalaireLiasseById: vi.fn(),
replaceSalaireLiasseIndex: vi.fn(),
}));
vi.mock("./storage", () => ({ storagePut: vi.fn(), storageGetBuffer: vi.fn() }));
vi.mock("pdf-parse/lib/pdf-parse.js", () => ({ default: vi.fn() }));
import { sdk } from "./_core/sdk";
import { createSalaireLiasse, getSalaireLiasseById, getSalaireLiasseByPeriod } from "./db";
import { encryptPayrollPdf } from "./payrollCrypto";
import { registerSalairesHttpRoutes } from "./salairesHttp";
import { storageGetBuffer, storagePut } from "./storage";
import pdfParse from "pdf-parse/lib/pdf-parse.js";
const EXTRACTED_TEXT = `##BULLETIN##05-2026##009999##DUPONT##TEST##
Matricule : 009999
Emploi : RESPONSABLE APPLICATIF Monsieur TEST DUPONT
Salaire de base 3 650.00
Salaire brut 3 650.00`;
function makeApp() {
const app = express();
registerSalairesHttpRoutes(app);
return app;
}
describe("routes HTTP Salaires", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.PAYROLL_ENCRYPTION_KEY = Buffer.alloc(32, 8).toString("base64");
(getSalaireLiasseByPeriod as ReturnType<typeof vi.fn>).mockResolvedValue(null);
});
it("interdit l'upload au profil non administrateur", async () => {
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 2, role: "standard" });
await request(makeApp())
.post("/api/salaires/liasses")
.field("annee", "2026")
.field("mois", "5")
.attach("file", Buffer.from("%PDF-1.7"), { filename: "mai.pdf", contentType: "application/pdf" })
.expect(403);
expect(createSalaireLiasse).not.toHaveBeenCalled();
expect(storagePut).not.toHaveBeenCalled();
});
it("chiffre et archive une liasse administrateur sans persister de donnée sensible", async () => {
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 1, role: "admin" });
(pdfParse as ReturnType<typeof vi.fn>).mockResolvedValue({ text: EXTRACTED_TEXT });
(storagePut as ReturnType<typeof vi.fn>).mockResolvedValue({ key: "salaires/2026/05/liasse_opaque.enc" });
(createSalaireLiasse as ReturnType<typeof vi.fn>).mockResolvedValue({ liasseId: 12, bulletins: 1 });
const response = await request(makeApp())
.post("/api/salaires/liasses")
.field("annee", "2026")
.field("mois", "5")
.attach("file", Buffer.from("%PDF-1.7 document de test"), { filename: "mai.pdf", contentType: "application/pdf" })
.expect(201);
expect(response.body).toMatchObject({ id: 12, bulletins: 1, statutExtraction: "ready" });
expect(createSalaireLiasse).toHaveBeenCalledWith(expect.objectContaining({
annee: 2026,
mois: 5,
importePar: 1,
stockageKey: "salaires/2026/05/liasse_opaque.enc",
bulletins: [expect.objectContaining({ matricule: "009999", brutMensuelCents: 365000 })],
}));
expect(JSON.stringify((createSalaireLiasse as ReturnType<typeof vi.fn>).mock.calls[0]?.[0])).not.toContain("N° SS");
});
it("sert un PDF déchiffré uniquement à un administrateur et sans mise en cache", async () => {
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 1, role: "admin" });
const plaintext = Buffer.from("%PDF-1.7 contenu de bulletin", "utf8");
const encrypted = encryptPayrollPdf(plaintext);
(getSalaireLiasseById as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 30001, stockageKey: "opaque", ivBase64: encrypted.ivBase64, authTagBase64: encrypted.authTagBase64, annee: 2026, mois: 5 });
(storageGetBuffer as ReturnType<typeof vi.fn>).mockResolvedValue(encrypted.ciphertext);
const response = await request(makeApp()).get("/api/salaires/liasses/30001/pdf").expect(200);
expect(response.headers["cache-control"]).toContain("no-store");
expect(response.headers["x-content-type-options"]).toBe("nosniff");
expect(response.headers["content-type"]).toContain("application/pdf");
expect(response.body).toEqual(plaintext);
});
it("interdit la consultation d'une liasse au profil lecture seule", async () => {
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 3, role: "readonly" });
await request(makeApp()).get("/api/salaires/liasses/12/pdf").expect(403);
expect(getSalaireLiasseById).not.toHaveBeenCalled();
});
it("réindexe une liasse archivée sans téléverser ni remplacer son PDF", async () => {
const { replaceSalaireLiasseIndex } = await import("./db");
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 1, role: "admin" });
const encrypted = encryptPayrollPdf(Buffer.from("%PDF-1.7 contenu de bulletin", "utf8"));
(getSalaireLiasseById as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 30001, stockageKey: "opaque", ivBase64: encrypted.ivBase64, authTagBase64: encrypted.authTagBase64, annee: 2026, mois: 5 });
(storageGetBuffer as ReturnType<typeof vi.fn>).mockResolvedValue(encrypted.ciphertext);
(pdfParse as ReturnType<typeof vi.fn>).mockResolvedValue({ text: EXTRACTED_TEXT });
(replaceSalaireLiasseIndex as ReturnType<typeof vi.fn>).mockResolvedValue({ bulletins: 1 });
await request(makeApp()).post("/api/salaires/liasses/30001/reindex").expect(200);
expect(replaceSalaireLiasseIndex).toHaveBeenCalledWith(30001, expect.objectContaining({
statutExtraction: "ready",
bulletins: [expect.objectContaining({ matricule: "009999" })],
}));
expect(storagePut).not.toHaveBeenCalled();
});
});

185
server/salairesHttp.ts Normal file
View File

@@ -0,0 +1,185 @@
import crypto from "node:crypto";
import type { Express, Request, Response } from "express";
import multer from "multer";
import { sdk } from "./_core/sdk";
import { createSalaireLiasse, getSalaireLiasseById, getSalaireLiasseByPeriod, replaceSalaireLiasseIndex } from "./db";
import { decryptPayrollPdf, encryptPayrollPdf } from "./payrollCrypto";
import { extractPayrollBulletins, type PayrollExtractionResult } from "./payrollParser";
import { extractPayrollPdfText } from "./payrollPdfText";
import { storageGetBuffer, storagePut } from "./storage";
const MAX_PDF_BYTES = 25 * 1024 * 1024;
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_PDF_BYTES, files: 1 },
});
function parsePeriod(value: unknown, min: number, max: number): number | null {
if (typeof value !== "string" || !/^\d+$/.test(value)) return null;
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : null;
}
function isPdf(buffer: Buffer): boolean {
return buffer.subarray(0, 5).toString("ascii") === "%PDF-";
}
function safeUploadFilename(value: string | undefined): string {
const normalized = (value ?? "liasse.pdf").replace(/[\\/\0]/g, "").trim();
return normalized.slice(0, 255) || "liasse.pdf";
}
async function getAdminUser(req: Request, res: Response) {
let user;
try {
user = await sdk.authenticateRequest(req);
} catch {
res.status(401).json({ error: "Authentification requise." });
return null;
}
if (user.role !== "admin") {
res.status(403).json({ error: "Accès réservé aux administrateurs." });
return null;
}
return user;
}
/**
* Routes REST limitées aux transferts binaires. Toutes les données métier sont
* lues via tRPC ; aucune URL de stockage ne quitte ce module serveur.
*/
export function registerSalairesHttpRoutes(app: Express) {
app.post("/api/salaires/liasses", upload.single("file"), async (req, res) => {
const user = await getAdminUser(req, res);
if (!user) return;
const annee = parsePeriod(req.body?.annee, 2020, 2100);
const mois = parsePeriod(req.body?.mois, 1, 12);
const file = req.file;
if (!annee || !mois || !file || !isPdf(file.buffer)) {
res.status(400).json({ error: "Transmettez une période valide et un fichier PDF conforme." });
return;
}
const alreadyImported = await getSalaireLiasseByPeriod(annee, mois);
if (alreadyImported) {
res.status(409).json({ error: "Une liasse est déjà archivée pour cette période. Elle ne peut pas être écrasée." });
return;
}
// pdf-parse est exécuté en mémoire : le texte des bulletins n'est ni journalisé
// ni envoyé au navigateur. Un échec d'analyse laisse une archive consultable.
let extraction: PayrollExtractionResult = {
bulletins: [],
statutExtraction: "a_controler" as const,
erreurExtraction: "La liasse a été archivée, mais son index doit être contrôlé manuellement.",
};
try {
extraction = extractPayrollBulletins(await extractPayrollPdfText(file.buffer));
} catch {
// Le message reste générique afin de ne pas divulguer de détail du contenu PDF.
}
try {
const encrypted = encryptPayrollPdf(file.buffer);
const archive = await storagePut(
`salaires/${annee}/${String(mois).padStart(2, "0")}/liasse.pdf.enc`,
encrypted.ciphertext,
"application/octet-stream",
);
const persisted = await createSalaireLiasse({
annee,
mois,
nomFichier: safeUploadFilename(file.originalname),
stockageKey: archive.key,
empreinteSha256: crypto.createHash("sha256").update(file.buffer).digest("hex"),
tailleOctets: file.buffer.length,
ivBase64: encrypted.ivBase64,
authTagBase64: encrypted.authTagBase64,
importePar: user.id,
statutExtraction: extraction.statutExtraction,
erreurExtraction: extraction.erreurExtraction,
bulletins: extraction.bulletins,
});
res.status(201).json({
id: persisted.liasseId,
bulletins: persisted.bulletins,
statutExtraction: extraction.statutExtraction,
avertissement: extraction.erreurExtraction,
});
} catch (error) {
console.error("[Salaires] Échec de l'archivage d'une liasse", error instanceof Error ? error.message : "erreur inconnue");
res.status(500).json({ error: "L'archivage sécurisé de la liasse a échoué. Aucun enregistrement n'a été créé." });
}
});
app.post("/api/salaires/liasses/:id/reindex", async (req, res) => {
const user = await getAdminUser(req, res);
if (!user) return;
const id = parsePeriod(req.params.id, 1, Number.MAX_SAFE_INTEGER);
if (!id) {
res.status(400).json({ error: "Référence de liasse invalide." });
return;
}
const liasse = await getSalaireLiasseById(id);
if (!liasse) {
res.status(404).json({ error: "Liasse introuvable." });
return;
}
try {
const ciphertext = await storageGetBuffer(liasse.stockageKey);
const pdf = decryptPayrollPdf({ ciphertext, ivBase64: liasse.ivBase64, authTagBase64: liasse.authTagBase64 });
if (!isPdf(pdf)) throw new Error("Invalid decrypted PDF signature");
const extraction = extractPayrollBulletins(await extractPayrollPdfText(pdf));
if (extraction.bulletins.length === 0) {
res.status(422).json({ error: "Aucun bulletin n'a pu être indexé. L'archive et l'index existant sont conservés." });
return;
}
const result = await replaceSalaireLiasseIndex(id, extraction);
res.json({ bulletins: result.bulletins, statutExtraction: extraction.statutExtraction, avertissement: extraction.erreurExtraction });
} catch (error) {
console.error("[Salaires] Échec de la réindexation d'une liasse", error instanceof Error ? error.message : "erreur inconnue");
res.status(503).json({ error: "La liasse ne peut pas être réindexée pour le moment. Son archive reste inchangée." });
}
});
app.get("/api/salaires/liasses/:id/pdf", async (req, res) => {
const user = await getAdminUser(req, res);
if (!user) return;
const id = parsePeriod(req.params.id, 1, Number.MAX_SAFE_INTEGER);
if (!id) {
res.status(400).json({ error: "Référence de liasse invalide." });
return;
}
const liasse = await getSalaireLiasseById(id);
if (!liasse) {
res.status(404).json({ error: "Liasse introuvable." });
return;
}
try {
const ciphertext = await storageGetBuffer(liasse.stockageKey);
const pdf = decryptPayrollPdf({
ciphertext,
ivBase64: liasse.ivBase64,
authTagBase64: liasse.authTagBase64,
});
if (!isPdf(pdf)) throw new Error("Invalid decrypted PDF signature");
res.set({
"Content-Type": "application/pdf",
"Content-Length": String(pdf.length),
"Content-Disposition": `inline; filename="bulletins-${liasse.annee}-${String(liasse.mois).padStart(2, "0")}.pdf"`,
"Cache-Control": "no-store, private",
Pragma: "no-cache",
"X-Content-Type-Options": "nosniff",
});
res.send(pdf);
} catch (error) {
console.error("[Salaires] Échec de la consultation d'une liasse", error instanceof Error ? error.message : "erreur inconnue");
res.status(503).json({ error: "Le document ne peut pas être consulté pour le moment." });
}
});
}

View File

@@ -95,3 +95,15 @@ export async function storageGetSignedUrl(relKey: string): Promise<string> {
const { url } = (await resp.json()) as { url: string };
return url;
}
/**
* Télécharge un objet uniquement depuis le serveur. Cette primitive est utilisée
* par la consultation de bulletins afin de ne jamais communiquer de lien de
* stockage ni de document chiffré au navigateur.
*/
export async function storageGetBuffer(relKey: string): Promise<Buffer> {
const signedUrl = await storageGetSignedUrl(relKey);
const response = await fetch(signedUrl, { redirect: "follow" });
if (!response.ok) throw new Error(`Storage download failed (${response.status})`);
return Buffer.from(await response.arrayBuffer());
}

6
server/types/pdf-parse-internal.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
declare module "pdf-parse/lib/pdf-parse.js" {
type PdfParseResult = { text: string };
type PdfParseOptions = { pagerender?: (pageData: unknown) => Promise<string> };
const pdfParse: (data: Buffer, options?: PdfParseOptions) => Promise<PdfParseResult>;
export default pdfParse;
}