feat(#102): validation NIR (format + clé 2A/2B) + warning cohérence

Made-with: Cursor
This commit is contained in:
MARTIN Julien 2026-02-26 11:23:22 +01:00
parent 85bfef7a6b
commit f46740c6ab
2 changed files with 123 additions and 1 deletions

View File

@ -0,0 +1,109 @@
/**
* Utilitaire de validation du NIR (numéro de sécurité sociale français).
* - Format 15 caractères (chiffres ou 2A/2B pour la Corse).
* - Clé de contrôle : 97 - (NIR13 mod 97). Pour 2A/2B, conversion temporaire (INSEE : 2A19, 2B20).
* - En cas d'incohérence avec les données (sexe, date, lieu) : warning uniquement, pas de rejet.
*/
const NIR_CORSE_2A = '19';
const NIR_CORSE_2B = '20';
/** Regex 15 caractères : sexe (1-3) + 4 chiffres + (2A|2B|2 chiffres) + 6 chiffres + 2 chiffres clé */
const NIR_FORMAT = /^[1-3]\d{4}(?:2A|2B|\d{2})\d{6}\d{2}$/i;
/**
* Convertit le NIR en chaîne de 13 chiffres pour le calcul de la clé (2A19, 2B20).
*/
export function nirTo13Digits(nir: string): string {
const n = nir.toUpperCase().replace(/\s/g, '');
if (n.length !== 15) return '';
const dept = n.slice(5, 7);
let deptNum: string;
if (dept === '2A') deptNum = NIR_CORSE_2A;
else if (dept === '2B') deptNum = NIR_CORSE_2B;
else deptNum = dept;
return n.slice(0, 5) + deptNum + n.slice(7, 13);
}
/**
* Vérifie que le format NIR est valide (15 caractères, 2A/2B acceptés).
*/
export function isNirFormatValid(nir: string): boolean {
if (!nir || typeof nir !== 'string') return false;
const n = nir.replace(/\s/g, '').toUpperCase();
return NIR_FORMAT.test(n);
}
/**
* Calcule la clé de contrôle attendue (97 - (NIR13 mod 97)).
* Retourne un nombre entre 1 et 97.
*/
export function computeNirKey(nir13: string): number {
const num = parseInt(nir13, 10);
if (Number.isNaN(num) || nir13.length !== 13) return -1;
return 97 - (num % 97);
}
/**
* Vérifie la clé de contrôle du NIR (15 caractères).
* Retourne true si le NIR est valide (format + clé).
*/
export function isNirKeyValid(nir: string): boolean {
const n = nir.replace(/\s/g, '').toUpperCase();
if (n.length !== 15) return false;
const nir13 = nirTo13Digits(n);
if (nir13.length !== 13) return false;
const expectedKey = computeNirKey(nir13);
const actualKey = parseInt(n.slice(13, 15), 10);
return expectedKey === actualKey;
}
export interface NirValidationResult {
valid: boolean;
error?: string;
warning?: string;
}
/**
* Valide le NIR (format + clé). En cas d'incohérence avec date de naissance ou sexe, ajoute un warning sans invalider.
*/
export function validateNir(
nir: string,
options?: { dateNaissance?: string; genre?: 'H' | 'F' },
): NirValidationResult {
const n = (nir || '').replace(/\s/g, '').toUpperCase();
if (n.length === 0) return { valid: false, error: 'Le NIR est requis' };
if (!isNirFormatValid(n)) {
return { valid: false, error: 'Le NIR doit contenir 15 caractères (chiffres, ou 2A/2B pour la Corse)' };
}
if (!isNirKeyValid(n)) {
return { valid: false, error: 'Clé de contrôle du NIR invalide' };
}
let warning: string | undefined;
if (options?.genre) {
const sexNir = n[0];
const expectedSex = options.genre === 'F' ? '2' : '1';
if (sexNir !== expectedSex) {
warning = 'Le NIR ne correspond pas au genre indiqué (position 1 du NIR).';
}
}
if (options?.dateNaissance) {
try {
const d = new Date(options.dateNaissance);
if (!Number.isNaN(d.getTime())) {
const year2 = d.getFullYear() % 100;
const month = d.getMonth() + 1;
const nirYear = parseInt(n.slice(1, 3), 10);
const nirMonth = parseInt(n.slice(3, 5), 10);
if (nirYear !== year2 || nirMonth !== month) {
warning = warning
? `${warning} Le NIR ne correspond pas à la date de naissance (positions 2-5).`
: 'Le NIR ne correspond pas à la date de naissance indiquée (positions 2-5).';
}
}
} catch {
// ignore
}
}
return { valid: true, warning };
}

View File

@ -23,6 +23,7 @@ import { ParentsChildren } from 'src/entities/parents_children.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { AppConfigService } from 'src/modules/config/config.service'; import { AppConfigService } from 'src/modules/config/config.service';
import { validateNir } from 'src/common/utils/nir.util';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@ -325,6 +326,18 @@ export class AuthService {
); );
} }
const nirNormalized = (dto.nir || '').replace(/\s/g, '').toUpperCase();
const nirValidation = validateNir(nirNormalized, {
dateNaissance: dto.date_naissance,
});
if (!nirValidation.valid) {
throw new BadRequestException(nirValidation.error || 'NIR invalide');
}
if (nirValidation.warning) {
// Warning uniquement : on ne bloque pas (AM souvent étrangères, DOM-TOM, Corse)
console.warn('[inscrireAMComplet] NIR warning:', nirValidation.warning, 'email=', dto.email);
}
const existe = await this.usersService.findByEmailOrNull(dto.email); const existe = await this.usersService.findByEmailOrNull(dto.email);
if (existe) { if (existe) {
throw new ConflictException('Un compte avec cet email existe déjà'); throw new ConflictException('Un compte avec cet email existe déjà');
@ -370,7 +383,7 @@ export class AuthService {
const am = amRepo.create({ const am = amRepo.create({
user_id: userEnregistre.id, user_id: userEnregistre.id,
approval_number: dto.numero_agrement, approval_number: dto.numero_agrement,
nir: dto.nir, nir: nirNormalized,
max_children: dto.capacite_accueil, max_children: dto.capacite_accueil,
biography: dto.biographie, biography: dto.biographie,
residence_city: dto.ville ?? undefined, residence_city: dto.ville ?? undefined,