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 { 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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user