feat(#102): validation NIR (format + clé 2A/2B) + warning cohérence
Made-with: Cursor
This commit is contained in:
parent
85bfef7a6b
commit
f46740c6ab
109
backend/src/common/utils/nir.util.ts
Normal file
109
backend/src/common/utils/nir.util.ts
Normal 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 : 2A→19, 2B→20).
|
||||
* - 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é (2A→19, 2B→20).
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
@ -23,6 +23,7 @@ import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { AppConfigService } from 'src/modules/config/config.service';
|
||||
import { validateNir } from 'src/common/utils/nir.util';
|
||||
|
||||
@Injectable()
|
||||
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);
|
||||
if (existe) {
|
||||
throw new ConflictException('Un compte avec cet email existe déjà');
|
||||
@ -370,7 +383,7 @@ export class AuthService {
|
||||
const am = amRepo.create({
|
||||
user_id: userEnregistre.id,
|
||||
approval_number: dto.numero_agrement,
|
||||
nir: dto.nir,
|
||||
nir: nirNormalized,
|
||||
max_children: dto.capacite_accueil,
|
||||
biography: dto.biographie,
|
||||
residence_city: dto.ville ?? undefined,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user