feat: implement codice fiscale calculation and validation

- Added `CalcolaCodiceFiscale` function to calculate Italian fiscal code based on name, surname, birth date, gender, and municipality code.
- Introduced utility functions for generating parts of the fiscal code, including handling special cases for names and surnames.
- Created tests for the fiscal code calculation covering various scenarios.
- Updated forms to enforce a maximum length for the fiscal code input.
- Added `vitest` for testing and configured it with Vite.
- Updated package dependencies to include testing libraries and Vite plugins.
This commit is contained in:
Marco Pedone 2026-03-05 15:17:09 +01:00
parent ef59e61545
commit 7b76132a94
8 changed files with 2743 additions and 163 deletions

File diff suppressed because it is too large Load diff

View file

@ -16,7 +16,8 @@
"stripe-dev": "stripe listen --forward-to https://localhost:3000/api/stripe-webhook", "stripe-dev": "stripe listen --forward-to https://localhost:3000/api/stripe-webhook",
"test": " npm run lint && npm run types ", "test": " npm run lint && npm run types ",
"types": "tsc --noEmit", "types": "tsc --noEmit",
"analyze": "cross-env ANALYZE=true next build" "analyze": "cross-env ANALYZE=true next build",
"vitest": "vitest run"
}, },
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
@ -111,6 +112,8 @@
"@biomejs/biome": "^2.4.2", "@biomejs/biome": "^2.4.2",
"@hookform/devtools": "^4.4.0", "@hookform/devtools": "^4.4.0",
"@react-email/preview-server": "^5.2.8", "@react-email/preview-server": "^5.2.8",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@total-typescript/ts-reset": "^0.6.1", "@total-typescript/ts-reset": "^0.6.1",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@types/node": "^24.2.0", "@types/node": "^24.2.0",
@ -121,13 +124,17 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/react-is": "^19.2.0", "@types/react-is": "^19.2.0",
"@types/uuid": "^11.0.0", "@types/uuid": "^11.0.0",
"@vitejs/plugin-react": "^5.1.4",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"jiti": "^2.6.1", "jiti": "^2.6.1",
"jsdom": "^28.1.0",
"kanel": "^3.16.1", "kanel": "^3.16.1",
"kanel-kysely": "^0.8.0", "kanel-kysely": "^0.8.0",
"knip": "^5.85.0", "knip": "^5.85.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"typescript": "^5.9.3" "typescript": "^5.9.3",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.0.18"
}, },
"ct3aMetadata": { "ct3aMetadata": {
"initVersion": "7.9.0" "initVersion": "7.9.0"

View file

@ -1136,6 +1136,7 @@ export const FormNewServizioAcquisto = ({
{...field} {...field}
className="uppercase" className="uppercase"
id="codice_fiscale" id="codice_fiscale"
maxLength={16}
onChange={async (v) => { onChange={async (v) => {
field.onChange(v.target.value.toUpperCase()); field.onChange(v.target.value.toUpperCase());
await form.trigger("codice_fiscale"); await form.trigger("codice_fiscale");

View file

@ -394,6 +394,7 @@ export const ProfileFormAnagrafica = ({
{...field} {...field}
className="uppercase" className="uppercase"
id="codice_fiscale" id="codice_fiscale"
maxLength={16}
onChange={async (v) => { onChange={async (v) => {
field.onChange(v.target.value.toUpperCase()); field.onChange(v.target.value.toUpperCase());
await form.trigger("codice_fiscale"); await form.trigger("codice_fiscale");

View file

@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { CalcolaCodiceFiscale } from "~/lib/codice_fiscale";
// Importa la tua funzione qui
// import { calcolaCF } from './ilTuoFile';
interface TestInput {
nome: string;
cognome: string;
dataNascita: string; // DD/MM/YYYY
sesso: "M" | "F";
catasto: string;
}
const testCases: { descrizione: string; input: TestInput; expected: string }[] =
[
{
descrizione: "Caso Francisco (Regola 1-3-4 e Stato Estero)",
input: {
nome: "Francisco Guillermo",
cognome: "Cespedes Catacora",
dataNascita: "1992-07-13",
sesso: "M",
catasto: "Z611",
},
expected: "CSPFNC92L13Z611Z",
},
{
descrizione: "Cognome e Nome corti (2 lettere) -> Pad con X",
input: {
nome: "Bo",
cognome: "Fu",
dataNascita: "1990-01-01",
sesso: "M",
catasto: "H501",
},
expected: "FUXBOX90A01H501F",
},
{
descrizione: "Cognome solo vocali",
input: {
nome: "Luca",
cognome: "Ao",
dataNascita: "1985-05-15",
sesso: "M",
catasto: "F205",
},
expected: "AOXLCU85E15F205Q",
},
{
descrizione: "Femmina + Dicembre (Giorno + 40 e Mese T)",
input: {
nome: "Anna",
cognome: "Rossi",
dataNascita: "1975-12-31",
sesso: "F",
catasto: "L219",
},
expected: "RSSNNA75T71L219W",
},
{
descrizione: "Nome con esattamente 3 consonanti (MRT)",
input: {
nome: "Marta",
cognome: "Rossi",
dataNascita: "1995-02-10",
sesso: "F",
catasto: "H501",
},
expected: "RSSMRT95B50H501Z",
},
{
descrizione: "Gestione apostrofi e spazi (D'Arco Maria Adele)",
input: {
nome: "Maria Adele",
cognome: "D'Arco",
dataNascita: "1980-03-05",
sesso: "F",
catasto: "A662",
},
expected: "DRCMDL80C45A662W",
},
{
descrizione: "Nato nel 2000 (Anno 00)",
input: {
nome: "Giuseppe",
cognome: "Verdi",
dataNascita: "2000-10-20",
sesso: "M",
catasto: "H501",
},
expected: "VRDGPP00R20H501M",
},
];
describe("Calcolo Codice Fiscale", () => {
it.each(testCases)("$descrizione", ({ input, expected }) => {
// const result = calcolaCF(input);
// expect(result).toBe(expected);
// Placeholder per la tua funzione
const { cf, omocodie } = CalcolaCodiceFiscale({
nome: input.nome,
cognome: input.cognome,
dataNascita: new Date(input.dataNascita),
sesso: input.sesso,
catasto: input.catasto,
});
const allValidCFs = [cf, ...omocodie];
expect(allValidCFs).toContain(expected);
});
});

View file

@ -0,0 +1,117 @@
export const CalcolaCodiceFiscale = ({
cognome,
nome,
dataNascita,
sesso,
catasto,
}: {
cognome: string;
nome: string;
dataNascita: Date;
sesso: "M" | "F";
catasto: string;
}) => {
const data = new Date(dataNascita);
const cf15 = `${calcoloCognome(cognome)}${calcoloNome(nome)}${data.getFullYear().toString().slice(-2)}${getMonthLetterCF(data.getMonth())}${getDayCF(data.getDate(), sesso)}${catasto}`;
const cf = `${cf15}${genCarattereControllo(cf15)}`;
const omocodie = genOmocodie(cf);
return {
cf,
omocodie,
};
};
function calcoloCognome(cognome: string): string {
const consonanti = cognome
.toUpperCase()
.replace(/[^BCDFGHJKLMNPQRSTVWXYZ]/g, "");
const vocali = cognome.toUpperCase().replace(/[^AEIOU]/g, "");
if (consonanti.length >= 3) {
return consonanti.substring(0, 3);
}
return `${consonanti + vocali}XXX`.substring(0, 3);
}
function calcoloNome(nome: string): string {
const consonanti = nome
.toUpperCase()
.replace(/[^BCDFGHJKLMNPQRSTVWXYZ]/g, "");
const vocali = nome.toUpperCase().replace(/[^AEIOU]/g, "");
if (consonanti.length >= 4) {
const c0 = consonanti[0];
const c2 = consonanti[2];
const c3 = consonanti[3];
if (!c0 || !c2 || !c3) {
throw new Error("Invalid name for CF generation");
}
// (4 o più consonanti): 1ª, 3ª e 4ª consonante
return c0 + c2 + c3;
}
// (meno di 4 consonanti): prime consonanti + eventuali vocali
const res = `${consonanti + vocali}XXX`.substring(0, 3);
return res;
}
const lettere_mesi = "ABCDEHLMPRST";
const getMonthLetterCF = (month: number) => {
return lettere_mesi[month];
};
const getDayCF = (day: number, sesso: string) => {
if (sesso === "M") {
return day.toString().padStart(2, "0");
}
return (day + 40).toString().padStart(2, "0");
};
const set1 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const set2 = "ABCDEFGHIJABCDEFGHIJKLMNOPQRSTUVWXYZ";
const setpari = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const setdisp = "BAKPLCQDREVOSFTGUHMINJWZYX";
const setcontrollo = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const genCarattereControllo = (str: string) => {
let s = 0;
for (let i = 1; i <= 13; i += 2)
s += setpari.indexOf(set2.charAt(set1.indexOf(str.charAt(i))));
for (let i = 0; i <= 14; i += 2)
s += setdisp.indexOf(set2.charAt(set1.indexOf(str.charAt(i))));
return setcontrollo[s % 26];
};
const AltMapping = "LMNPQRSTUV";
const genOmocodie = (input: string): string[] => {
const substring = input.slice(0, Math.min(input.length, 15));
const result = [];
let wrk_string = substring;
for (let i = substring.length - 1; i >= 0; i--) {
const char = substring[i];
if (char && !Number.isNaN(parseInt(char))) {
const str_arr = wrk_string.split("");
const charNum = parseInt(char);
if (charNum > 9) {
throw new Error("Invalid CF");
}
const mappedChar = AltMapping[charNum];
if (!mappedChar) {
throw new Error("Invalid CF");
}
str_arr[i] = mappedChar;
wrk_string = str_arr.join("");
result.push(wrk_string);
}
}
return result;
};

View file

@ -1,9 +1,9 @@
import dynamic from "next/dynamic";
import type { ChangeEvent } from "react"; import type { ChangeEvent } from "react";
import type { z } from "zod/v4"; import type { z } from "zod/v4";
import type { Comuni } from "~/schemas/public/Comuni"; import type { Comuni } from "~/schemas/public/Comuni";
import type { Nazioni } from "~/schemas/public/Nazioni"; import type { Nazioni } from "~/schemas/public/Nazioni";
import { ITALY_CATASTO_CODE } from "./catasto"; import { ITALY_CATASTO_CODE } from "./catasto";
import { CalcolaCodiceFiscale } from "./codice_fiscale";
export const NullableStringOnChange = ( export const NullableStringOnChange = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
@ -13,121 +13,6 @@ export const NullableStringOnChange = (
fn(e.target.value && e.target.value !== "" ? e.target.value : null); fn(e.target.value && e.target.value !== "" ? e.target.value : null);
}; };
export const FormDebug: React.ElementType =
process.env.NODE_ENV === "development"
? dynamic(
() => import("@hookform/devtools").then((module) => module.DevTool),
{ ssr: false },
)
: () => null; // Return null component in production
const lettere_mesi = "ABCDEHLMPRST";
const getMonthLetterCF = (month: number) => {
return lettere_mesi[month];
};
const getDayCF = (day: number, sesso: string) => {
if (sesso === "M") {
return day.toString().padStart(2, "0");
}
return (day + 40).toString().padStart(2, "0");
};
const AltMapping = "LMNPQRSTUV";
const getAlternativeCF = (input: string): string[] => {
const substring = input.slice(0, Math.min(input.length, 15));
const result = [];
let wrk_string = substring;
for (let i = substring.length - 1; i >= 0; i--) {
const char = substring[i];
if (char && !Number.isNaN(parseInt(char))) {
const str_arr = wrk_string.split("");
if (parseInt(char) > 9) {
throw new Error("Invalid CF");
}
// biome-ignore lint/style/noNonNullAssertion: <known length>
str_arr[i] = AltMapping[parseInt(char)]!;
wrk_string = str_arr.join("");
result.push(wrk_string);
}
}
return result;
};
function calcolaParteCognome(cognome: string): string {
const consonanti = cognome
.toUpperCase()
.replace(/[^BCDFGHJKLMNPQRSTVWXYZ]/g, "");
const vocali = cognome.toUpperCase().replace(/[^AEIOU]/g, "");
if (consonanti.length >= 3) {
return consonanti.substring(0, 3);
}
return `${consonanti + vocali}XXX`.substring(0, 3);
}
function calcolaParteNome(nome: string): string {
const consonanti = nome
.toUpperCase()
.replace(/[^BCDFGHJKLMNPQRSTVWXYZ]/g, "");
const vocali = nome.toUpperCase().replace(/[^AEIOU]/g, "");
if (consonanti.length >= 4) {
const c0 = consonanti[0];
const c2 = consonanti[2];
const c3 = consonanti[3];
if (!c0 || !c2 || !c3) {
throw new Error("Invalid name for CF generation");
}
// (4 o più consonanti): 1ª, 3ª e 4ª consonante
return c0 + c2 + c3;
}
// (meno di 4 consonanti): prime consonanti + eventuali vocali
const res = `${consonanti + vocali}XXX`.substring(0, 3);
return res;
}
const gen15CF = ({
cognome,
nome,
dataNascita,
sesso,
catasto,
}: {
cognome: string;
nome: string;
dataNascita: Date;
sesso: string;
catasto: string;
}): string => {
const data = new Date(dataNascita);
return `${calcolaParteCognome(cognome)}${calcolaParteNome(nome)}${data.getFullYear().toString().slice(-2)}${getMonthLetterCF(data.getMonth())}${getDayCF(data.getDate(), sesso)}${catasto}`;
};
const set1 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const set2 = "ABCDEFGHIJABCDEFGHIJKLMNOPQRSTUVWXYZ";
const setpari = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const setdisp = "BAKPLCQDREVOSFTGUHMINJWZYX";
const setcontrollo = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const genCarattereControllo = (str: string) => {
let s = 0;
for (let i = 1; i <= 13; i += 2)
s += setpari.indexOf(set2.charAt(set1.indexOf(str.charAt(i))));
for (let i = 0; i <= 14; i += 2)
s += setdisp.indexOf(set2.charAt(set1.indexOf(str.charAt(i))));
return setcontrollo[s % 26];
};
export const checkFiscalCodeValidity = ({ export const checkFiscalCodeValidity = ({
path, path,
codiceFiscale, codiceFiscale,
@ -170,21 +55,21 @@ export const checkFiscalCodeValidity = ({
} }
} }
} }
if (sesso !== "M" && sesso !== "F") {
throw new Error("Invalid sex for CF generation");
}
const gen = gen15CF({ const { cf, omocodie } = CalcolaCodiceFiscale({
cognome: cognomeUpper, cognome: cognomeUpper,
dataNascita, dataNascita,
catasto: codiceCatasto, catasto: codiceCatasto,
nome: nomeUpper, nome: nomeUpper,
sesso, sesso,
}); });
const control = genCarattereControllo(gen);
console.log("Generated CF:", gen + control);
console.log("Provided CF:", codiceFiscale);
if ( if (
gen !== codiceFiscale.slice(0, 15) && codiceFiscale.slice(0, 15) !== cf.slice(0, 15) &&
!(codiceFiscale in getAlternativeCF(gen)) !(codiceFiscale in omocodie)
) { ) {
refinementContext.issues.push({ refinementContext.issues.push({
code: "custom", code: "custom",
@ -194,7 +79,7 @@ export const checkFiscalCodeValidity = ({
}); });
} }
if (control !== codiceFiscale.slice(-1)) { if (codiceFiscale.slice(-1) !== cf.slice(-1)) {
refinementContext.issues.push({ refinementContext.issues.push({
code: "custom", code: "custom",
input: "", input: "",
@ -224,7 +109,7 @@ export const checkFiscalCodeValidity = ({
}); });
} }
if (codiceFiscale.slice(0, 3) !== calcolaParteCognome(cognomeUpper)) { if (codiceFiscale.slice(0, 3) !== cf.slice(0, 3)) {
refinementContext.issues.push({ refinementContext.issues.push({
code: "custom", code: "custom",
input: "", input: "",
@ -232,7 +117,7 @@ export const checkFiscalCodeValidity = ({
path: [path], path: [path],
}); });
} }
if (codiceFiscale.slice(3, 6) !== calcolaParteNome(nomeUpper)) { if (codiceFiscale.slice(3, 6) !== cf.slice(3, 6)) {
refinementContext.issues.push({ refinementContext.issues.push({
code: "custom", code: "custom",
input: "", input: "",
@ -241,9 +126,7 @@ export const checkFiscalCodeValidity = ({
}); });
} }
if ( if (codiceFiscale.slice(6, 8) !== cf.slice(6, 8)) {
codiceFiscale.slice(6, 8) !== dataNascita.getFullYear().toString().slice(-2)
) {
refinementContext.issues.push({ refinementContext.issues.push({
code: "custom", code: "custom",
input: "", input: "",
@ -252,7 +135,7 @@ export const checkFiscalCodeValidity = ({
}); });
} }
if (codiceFiscale.slice(8, 9) !== getMonthLetterCF(dataNascita.getMonth())) { if (codiceFiscale.slice(8, 9) !== cf.slice(8, 9)) {
refinementContext.issues.push({ refinementContext.issues.push({
code: "custom", code: "custom",
input: "", input: "",
@ -260,7 +143,7 @@ export const checkFiscalCodeValidity = ({
path: [path], path: [path],
}); });
} }
if (codiceFiscale.slice(9, 11) !== getDayCF(dataNascita.getDate(), sesso)) { if (codiceFiscale.slice(9, 11) !== cf.slice(9, 11)) {
refinementContext.issues.push({ refinementContext.issues.push({
code: "custom", code: "custom",
input: "", input: "",

View file

@ -0,0 +1,10 @@
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [tsconfigPaths(), react()],
test: {
environment: "jsdom",
},
});