UNPKG

@marketto/codice-fiscale-utils

Version:

TS & JS utilities to handle Italian Codice Fiscale

726 lines (654 loc) 20 kB
import dayjs from "dayjs"; import { IBelfioreConnector, BelfiorePlace, } from "@marketto/belfiore-connector"; import DiacriticRemover from "@marketto/diacritic-remover"; import { CRC_OFFSET, CRC_SIZE, DAY_OFFSET, DAY_SIZE, FIRSTNAME_OFFSET, FIRSTNAME_SIZE, GENDER_OFFSET, GENDER_SIZE, LASTNAME_OFFSET, LASTNAME_SIZE, MONTH_OFFSET, MONTH_SIZE, PLACE_OFFSET, PLACE_SIZE, YEAR_OFFSET, YEAR_SIZE, } from "../const/cf-offsets.const"; import { CF_NAME_MATCHER, CF_SURNAME_MATCHER } from "../const/matcher.const"; import { CONSONANT_LIST, VOWEL_LIST } from "../const/matcher.const"; import { DateDay, DateMonth, DateUtils, MultiFormatDate } from "../date-utils"; import BirthMonth from "../enums/birth-month.enum"; import GenderWeight from "../enums/gender-weight.enum"; import Omocodes from "../enums/omocodes.enum"; import type IPersonalInfo from "../interfaces/personal-info.interface"; import type Genders from "../types/genders.type"; import CheckDigitizer from "./check-digitizer.class"; import Gender from "./gender.class"; import { CF_INTRODUCTION_DATE } from "../const/logic.const"; const diacriticRemover = new DiacriticRemover(); export default class Parser { constructor(private readonly belfioreConnector: IBelfioreConnector) {} /** * Default omocode bitmap */ public OMOCODE_BITMAP: number = 0b0111011011000000; /** * Convert omocode CF into plain one * @param codiceFiscale Partial or complete Omocode/Regular CF to parse, starting from LastName * @returns Regular CF w/o omocodes chars */ public cfDeomocode(codiceFiscale: string): string { if (codiceFiscale && codiceFiscale.length <= YEAR_OFFSET) { return codiceFiscale; } const deomocodedCf = this.partialCfDeomocode(codiceFiscale); if (deomocodedCf.length < CRC_OFFSET) { return deomocodedCf; } const partialDeomocodedCf = deomocodedCf.substring( LASTNAME_OFFSET, CRC_OFFSET ); return ( partialDeomocodedCf + this.appyCaseToChar( CheckDigitizer.checkDigit(deomocodedCf) || "", deomocodedCf.substring(CRC_OFFSET, CRC_OFFSET + CRC_SIZE) ) ); } public cfOmocode(codiceFiscale: string, omocodeId: number): string { if (!omocodeId) { return this.cfDeomocode(codiceFiscale); } const omocodedCf = codiceFiscale.split(""); // tslint:disable-next-line: prefer-for-of for (let i = codiceFiscale.length - 1, o = 0; i >= 0; i--) { // tslint:disable-next-line: no-bitwise if ((2 ** i) & this.OMOCODE_BITMAP) { // tslint:disable-next-line: no-bitwise const charToEncode: boolean = !!(omocodeId & (2 ** o)); const isOmocode: boolean = isNaN(parseInt(omocodedCf[i], 10)); if (charToEncode !== isOmocode) { const char: any = omocodedCf[i].toUpperCase(); omocodedCf[i] = Omocodes[char]; } o++; } } const crc = omocodedCf[CRC_OFFSET]; if (crc) { const partialCf = omocodedCf.slice(LASTNAME_OFFSET, CRC_OFFSET).join(""); omocodedCf[CRC_OFFSET] = this.appyCaseToChar( CheckDigitizer.checkDigit(partialCf) || "", crc ); } return omocodedCf.join(""); } public cfOmocodeId(codiceFiscale: string): number { const cfOmocodeBitmap = codiceFiscale .split("") // tslint:disable-next-line: no-bitwise .filter((char, index) => (2 ** index) & this.OMOCODE_BITMAP) .map((char) => (/^[a-z]$/i.test(diacriticRemover[char]) ? 1 : 0)) .join(""); return parseInt(cfOmocodeBitmap, 2); } /** * Parse lastName information * @param codiceFiscale Partial or complete CF to parse * @returns Partial/possible lastName */ public cfToLastName(codiceFiscale: string): string | null { const cfLastNamePart = codiceFiscale?.substring( LASTNAME_OFFSET, LASTNAME_OFFSET + LASTNAME_SIZE ); if ( typeof codiceFiscale !== "string" || cfLastNamePart.length !== LASTNAME_SIZE || !new RegExp(`^(?:${CF_SURNAME_MATCHER})`, "iu").test(cfLastNamePart) ) { return null; } const lastNameCf = codiceFiscale.substring( LASTNAME_OFFSET, LASTNAME_OFFSET + LASTNAME_SIZE ); const [cons = ""] = lastNameCf.match(new RegExp(`^[${CONSONANT_LIST}]{1,3}`, "ig")) || []; const [vow = ""] = lastNameCf.match(new RegExp(`[${VOWEL_LIST}]{1,3}`, "ig")) || []; const matchingLength = cons.length + vow.length; if ( matchingLength < 2 || (matchingLength < 3 && lastNameCf[2].toUpperCase() !== "X") ) { return null; } switch (cons.length) { case 3: return (cons + vow).split("").join(this.JOLLY_CHAR) + this.JOLLY_CHAR; case 2: return `${cons[0]}${vow[0]}*${cons[1]}${this.JOLLY_CHAR}`; case 1: return `${cons[0]}${vow}${this.JOLLY_CHAR}`; default: return `${vow}${vow.length === 3 ? this.JOLLY_CHAR : ""}`; } } /** * Parse firstName information * @param codiceFiscale Partial or complete CF to parse * @returns Partial/possible firstName */ public cfToFirstName(codiceFiscale: string): string | null { const cfFirstNamePart = codiceFiscale?.substring( FIRSTNAME_OFFSET, FIRSTNAME_OFFSET + FIRSTNAME_SIZE ); if ( typeof codiceFiscale !== "string" || cfFirstNamePart?.length !== FIRSTNAME_SIZE || !new RegExp(`^(${CF_NAME_MATCHER})$`, "iu").test(cfFirstNamePart) ) { return null; } return this.cfToLastName(cfFirstNamePart); } /** * Parse gender information * @param codiceFiscale Partial or complete CF to parse * @returns Male or female */ public cfToGender(codiceFiscale: string): Genders | null { if ( typeof codiceFiscale !== "string" || codiceFiscale.length < GENDER_OFFSET + GENDER_SIZE ) { return null; } const cfGenderPart = codiceFiscale.substring( GENDER_OFFSET, GENDER_OFFSET + GENDER_SIZE ); const genderInt = parseInt(this.partialCfDeomocode(cfGenderPart, GENDER_OFFSET), 10) * 10; return Gender.getGender(genderInt); } /** * Parse birth year information * @param codiceFiscale Partial or complete CF to parse * @returns Birth Year (4 digits) */ public cfToBirthYear(codiceFiscale: string): number | null { if ( typeof codiceFiscale !== "string" || codiceFiscale.length < YEAR_OFFSET + YEAR_SIZE ) { return null; } const cfBirthYearPart = codiceFiscale.substring( YEAR_OFFSET, YEAR_OFFSET + YEAR_SIZE ); const birthYear: number = parseInt( this.partialCfDeomocode(cfBirthYearPart, YEAR_OFFSET), 10 ); if (isNaN(birthYear)) { return null; } const current2DigitsYear: number = parseInt(dayjs().format("YY"), 10); const century: number = (birthYear > current2DigitsYear ? 1 : 0) * 100; return dayjs() .subtract(current2DigitsYear - birthYear + century, "years") .year(); } /** * Parse birth month information * @param codiceFiscale Partial or complete CF to parse * @returns Birth Month (0...11 - Date notation) */ public cfToBirthMonth(codiceFiscale: string): DateMonth | null { if ( typeof codiceFiscale !== "string" || codiceFiscale.length < MONTH_OFFSET + MONTH_SIZE ) { return null; } const cfBirthMonthPart: any = codiceFiscale .substring(MONTH_OFFSET, MONTH_OFFSET + MONTH_SIZE) .toUpperCase(); const birthMonth = BirthMonth[cfBirthMonthPart]; if (typeof birthMonth !== "number" || birthMonth < 0 || birthMonth > 11) { return null; } return birthMonth as DateMonth; } /** * Parse birth day information * @param codiceFiscale Partial or complete CF to parse * @returns Birth day (1..31) */ public cfToBirthDay(codiceFiscale: string): DateDay | null { if ( typeof codiceFiscale !== "string" || codiceFiscale.length < DAY_OFFSET + DAY_SIZE ) { return null; } const cfBirthDayPart = codiceFiscale.substring( DAY_OFFSET, DAY_OFFSET + DAY_SIZE ); const birthDay: number = parseInt( this.partialCfDeomocode(cfBirthDayPart, DAY_OFFSET), 10 ); if (isNaN(birthDay)) { return null; } return Gender.getDay(birthDay); } /** * Parse birth date information * @param codiceFiscale Partial or complete CF to parse * @returns Birth Date */ public cfToBirthDate(codiceFiscale: string): Date | null { const birthDay = this.cfToBirthDay(codiceFiscale); if (!birthDay) { return null; } const birthMonth = this.cfToBirthMonth(codiceFiscale); if (typeof birthMonth !== "number") { return null; } const birthYear = this.cfToBirthYear(codiceFiscale); return DateUtils.ymdToDate(birthYear, birthMonth, birthDay); } /** * Parse birth place information * @param codiceFiscale Partial or complete CF to parse * @param checkBirthDateConsistency Ensure birthday is between creation and expiran date of the cf city or country, default value: true * @returns Birth place */ public async cfToBirthPlace( codiceFiscale: string, checkBirthDateConsistency: boolean = true ): Promise<BelfiorePlace | null> { if ( typeof codiceFiscale !== "string" || codiceFiscale.length < PLACE_OFFSET + PLACE_SIZE ) { return null; } const cfBirthPlacePart = codiceFiscale.substring( PLACE_OFFSET, PLACE_OFFSET + PLACE_SIZE ); const belfioreCode: string = this.partialCfDeomocode( cfBirthPlacePart, PLACE_OFFSET ); const birthPlace: BelfiorePlace | undefined | null = await this.belfioreConnector.findByCode(belfioreCode); if (!birthPlace) { return null; } const { creationDate, expirationDate } = birthPlace; if ((creationDate || expirationDate) && checkBirthDateConsistency) { const birthDate = this.cfToBirthDate(codiceFiscale); const isBirthDateAfterCfIntroduction = dayjs(CF_INTRODUCTION_DATE) // Adding some tolerance .add(5, "years") .isBefore(birthDate, "day"); // Skipping birthDate vs Creation/Expiration check for people born up to 5y after cf introduction if (birthDate && isBirthDateAfterCfIntroduction) { const datePlaceConsistency = // BirthDay is before expiration date (!expirationDate || dayjs(birthDate).isBefore(expirationDate, "day")) && // BirthDay is after creation date (!creationDate || dayjs(birthDate).isAfter(creationDate, "day")); if (!datePlaceConsistency) { return null; } } } return birthPlace; } /** * @param fiscalCode 16 character Codice Fiscale to decode * @returns Decoded CF Info */ public async cfDecode(fiscalCode: string): Promise<IPersonalInfo> { const year = this.cfToBirthYear(fiscalCode) || undefined; // 0 is a month const month = this.cfToBirthMonth(fiscalCode) ?? undefined; const day = this.cfToBirthDay(fiscalCode) || undefined; const date = DateUtils.ymdToDate(year, month, day) || undefined; const place = await this.cfToBirthPlace(fiscalCode); const personalInfo: IPersonalInfo = { firstName: this.cfToFirstName(fiscalCode) || undefined, lastName: this.cfToLastName(fiscalCode) || undefined, day, month, year, date, gender: this.cfToGender(fiscalCode) || undefined, place: place || undefined, omocodeId: this.cfOmocodeId(fiscalCode), }; if (year && month && day) { personalInfo.date = new Date(Date.UTC(year, month, day)); } return personalInfo; } /** * Parse lastName to cf part * @param lastName Partial or complete CF to parse * @returns partial cf */ public lastNameToCf(lastName?: string | null): string | null { if (!lastName || (lastName || "").trim().length < 2) { return null; } if (!/^[A-Z ']{1,32}$/iu.test(diacriticRemover.replace(lastName))) { return null; } const consonants = this.charExtractor(lastName, CONSONANT_LIST); const vowels = this.charExtractor(lastName, VOWEL_LIST); const partialCf = (consonants + vowels).padEnd(3, "X").substring(0, 3); if (partialCf.length < 3) { return null; } return partialCf.toUpperCase(); } /** * Parse firstName to cf part * @param firstName Partial or complete CF to parse * @returns partial cf */ public firstNameToCf(firstName?: string | null): string | null { if (!firstName || (firstName || "").trim().length < 2) { return null; } const consonants = this.charExtractor(firstName, CONSONANT_LIST); if (consonants.length >= 4) { return (consonants[0] + consonants.substring(2, 4)).toUpperCase(); } return this.lastNameToCf(firstName); } /** * Parse year to cf part * @param year Birth year 2 or 4 digit string, number above 19XX or below 100 * @returns partial cf */ public yearToCf(year: string | number): string | null { let parsedYear: number; if (typeof year === "string") { parsedYear = parseInt(year, 10); } else { parsedYear = year; } if ( !( typeof parsedYear === "number" && !isNaN(parsedYear) && (parsedYear >= 1900 || parsedYear < 100) ) ) { return null; } return `0${parsedYear}`.substr(-2); } /** * Parse month information * @param month Month number 0..11 * @returns Birth Month CF code */ public monthToCf(month: DateMonth | number): string | null { if (month < 0 || month > 11) { return null; } return BirthMonth[month] || null; } /** * Parse day information * @param day Day number 1..31 * @param gender Gender enum value * @returns Birth Day CF code */ public dayGenderToCf(day: DateDay | number, gender: Genders): string | null { if (day < 1 || day > 31) { return null; } const genderValue = GenderWeight[gender as any]; if (typeof genderValue !== "number") { return null; } return `0${day + genderValue}`.substr(-2); } /** * Parse Year, Month, Day to Dated * @param year 4 digits Year * @param month 1 or 2 digits Month 0..11 * @param day 1,2 digits Day 1..31 * @returns Date or null if provided year/month/day are not valid */ public yearMonthDayToDate( year: number | null | undefined, month: DateMonth | null | undefined = 0, day: DateDay | null | undefined = 1 ): Date | null { if ( !year || year < 1861 || [month, day].some((param) => typeof param !== "number") ) { return null; } const date = dayjs(Date.UTC(year, month || 0, day || 1)); if ( !date.isValid() || date.year() !== year || date.month() !== month || date.date() !== day ) { return null; } return date.toDate(); } /** * Parse Place information to return city or country details * @param place Belfiore place instance, belfiore code or city/country name * @returns BelfiorePlace instance with the target city or country details */ public async parsePlace( place: BelfiorePlace | string ): Promise<BelfiorePlace | null> { let verifiedBirthPlace: BelfiorePlace | null | undefined; if (!place) { return null; } else if (typeof place === "object" && place.belfioreCode) { verifiedBirthPlace = await this.belfioreConnector.findByCode( place.belfioreCode ); } else if (typeof place === "string") { verifiedBirthPlace = (await this.belfioreConnector.findByCode(place)) || (await this.belfioreConnector.findByName(place)); } return verifiedBirthPlace || null; } /** * Parse Date and Gender information to create Date/Gender CF part * @param date Date instance, ISO8601 date string or array of numbers [year, month, day] * @param gender Gender enum value * @returns Birth date and Gender CF code */ public dateGenderToCf(date: MultiFormatDate, gender: Genders): string | null { const parsedDate = DateUtils.parseDate(date); if (!parsedDate) { return null; } const cfYear = this.yearToCf(parsedDate.getFullYear()); const cfMonth = this.monthToCf(parsedDate.getMonth()); const cfDayGender = this.dayGenderToCf(parsedDate.getDate(), gender); return `${cfYear}${cfMonth}${cfDayGender}`; } /** * Parse place name and province to Belfiore code * @param cityOrCountryName City or Country name * @param provinceId Province code for cities * @returns Matching place belfiore code, if only once is matching criteria */ /** * Parse a Date and Gender information to create Date/Gender CF part * @param birthDate Date instance, ISO8601 date string or array of numbers [year, month, day] * @param cityOrCountryName City or Country name * @param provinceId Province code for cities * @returns Matching place belfiore code, if only once is matching criteria */ public async placeToCf( cityOrCountryName: string, provinceId?: string ): Promise<string | null>; public async placeToCf( birthDate: MultiFormatDate, cityOrCountryName: string, provinceId?: string ): Promise<string | null>; public async placeToCf( dateOrName: MultiFormatDate, nameOrProvince?: string, provinceId?: string ): Promise<string | null> { const birthDate: Date | null = DateUtils.parseDate(dateOrName); let name: string; let province: string | undefined; if (!birthDate && typeof dateOrName === "string") { name = dateOrName; province = nameOrProvince; } else if (nameOrProvince) { name = nameOrProvince; province = provinceId; } else { return null; } let placeFinder: IBelfioreConnector | undefined = this.belfioreConnector; if (province) { placeFinder = placeFinder.byProvince(province); } if (birthDate && placeFinder) { placeFinder = placeFinder.from(birthDate); } if (placeFinder) { const foundPlace: BelfiorePlace | null = await new Parser( placeFinder ).parsePlace(name); if (foundPlace) { return foundPlace.belfioreCode; } } return null; } /** * Generates full CF * @returns Complete CF */ public async encodeCf({ lastName, firstName, year, month, day, date, gender, place, omocodeId = 0, }: Omit<IPersonalInfo, "place"> & { place?: BelfiorePlace | string | undefined; }): Promise<string | null> { const dtParams = DateUtils.parseDate(date) || this.yearMonthDayToDate(year, month, day); if (!(dtParams && lastName && firstName && gender && place)) { return null; } const generator = [ async () => this.lastNameToCf(lastName), async () => this.firstNameToCf(firstName), async () => this.dateGenderToCf(dtParams, gender), async () => await this.placeToCf( dtParams, (place as BelfiorePlace)?.belfioreCode || (place as string) ), ]; let cf = ""; for (const cfPartGenerator of generator) { const cfValue = await cfPartGenerator(); if (!cfValue) { return null; } cf += cfValue; } return this.cfOmocode(cf, omocodeId); } private JOLLY_CHAR: string = "*"; private checkBitmap(offset: number): boolean { // tslint:disable-next-line: no-bitwise return !!((2 ** offset) & this.OMOCODE_BITMAP); } private charOmocode(char: string, offset: number): string { if (/^[A-Z]$/giu.test(char) && this.checkBitmap(offset)) { return Omocodes[char.toUpperCase() as any]; } return char; } private charExtractor(text: string, CHAR_LIST: string): string { const charMatcher = new RegExp(`[${CHAR_LIST}]{1,24}`, "ig"); const diacriticFreeText = diacriticRemover.replace(text).trim(); const matchingChars = diacriticFreeText.match(charMatcher); return (matchingChars || []).join(""); } /** * Convert omocode full or chunk CF into plain one * @param partialCodiceFiscale Partial or complete Omocode/Regular CF to parse * @param offset starting point of the given chunk in the 16 char CF * @returns Regular version w/o omocodes chars of the given chunk */ private partialCfDeomocode( partialCodiceFiscale: string, offset: number = 0 ): string { const charReplacer = (char: string, position: number) => this.charOmocode(char, position + offset); return partialCodiceFiscale.replace(/[\dA-Z]/giu, charReplacer); } private appyCaseToChar(targetChar: string, counterCaseChar: string): string { if (targetChar && counterCaseChar) { const isUpperCase = counterCaseChar[0] === counterCaseChar[0].toUpperCase(); const isLowerCase = counterCaseChar[0] === counterCaseChar[0].toLowerCase(); if (isUpperCase && !isLowerCase) { return targetChar[0].toUpperCase(); } else if (!isUpperCase && isLowerCase) { return targetChar[0].toLowerCase(); } } return targetChar[0]; } }