From 797e2fa908ce713fcc31a00950ea2aaaa83d2df8 Mon Sep 17 00:00:00 2001 From: Stefano Rossotti Date: Wed, 23 Sep 2026 16:41:56 +0200 Subject: [PATCH] feat: add optional issuingdate for foreign birthplace encoding For foreign countries (province EE), fiscal codes use the country code valid at first document issuance rather than at birth. Optional issuingdate on encode and encode_birthplace selects that code; Italian municipalities are unchanged. CLI adds --issuingdate for the encode subcommand. --- README.md | 24 ++++++++++++ src/codicefiscale/cli.py | 10 +++++ src/codicefiscale/codicefiscale.py | 59 +++++++++++++++++++++++------- tests/test_cli.py | 21 ++++++++++- tests/test_encode.py | 49 +++++++++++++++++++++++++ 5 files changed, 148 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index d1a1b56..a61d1c5 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,24 @@ codicefiscale.encode( # "CCCFBA85D03L219P" ``` + +For people born abroad, the birthplace code in the fiscal code reflects the country +code assigned at **first document issuance**, not at birth. Pass optional +`issuingdate` to select the foreign country code valid on that date (Italian +municipalities always use the code valid at `birthdate`): + +```python +codicefiscale.encode( + lastname="Rossi", + firstname="Mario", + gender="M", + birthdate="20/08/1987", + birthplace="Georgia", + issuingdate="01/01/2000", +) + +# birthplace code Z254 instead of Z136 (valid at birth in 1987) +``` #### Decode ```python codicefiscale.decode("CCCFBA85D03L219P") @@ -119,6 +137,12 @@ python -m codicefiscale --help python -m codicefiscale encode --firstname Fabio --lastname Caccamo --gender M --birthdate 03/04/1985 --birthplace Torino ``` +Optional `--issuingdate` (foreign birthplaces only): + +```bash +python -m codicefiscale encode --firstname Mario --lastname Rossi --gender M --birthdate 20/08/1987 --birthplace Georgia --issuingdate 01/01/2000 +``` + #### Decode (CLI) ```bash python -m codicefiscale decode CCCFBA85D03L219P diff --git a/src/codicefiscale/cli.py b/src/codicefiscale/cli.py index d3d0732..adc0de1 100644 --- a/src/codicefiscale/cli.py +++ b/src/codicefiscale/cli.py @@ -15,6 +15,7 @@ def _encode_from_args(args: argparse.Namespace) -> None: gender=args.gender, birthdate=args.birthdate, birthplace=args.birthplace, + issuingdate=args.issuingdate, ) except Exception as error: sys.stderr.write(f"{error}\n") @@ -119,6 +120,15 @@ def run() -> None: required=True, help="Place of birth (city, province)", ) + encode_parser.add_argument( + "--issuingdate", + required=False, + default=None, + help=( + "Document issuing date (DD/MM/YYYY); for foreign birthplaces, " + "selects the country code valid at this date" + ), + ) validate_parser = subparsers.add_parser( "validate", diff --git a/src/codicefiscale/codicefiscale.py b/src/codicefiscale/codicefiscale.py index d5fd7b2..844a118 100644 --- a/src/codicefiscale/codicefiscale.py +++ b/src/codicefiscale/codicefiscale.py @@ -152,6 +152,7 @@ def _get_date( def _get_birthplace( birthplace: str, birthdate: datetime | str | None = None, + issuingdate: datetime | str | None = None, ) -> dict[str, Any] | None: birthplace_unicode_slug = slugify(birthplace, allow_unicode=True) birthplace_slug = slugify(birthplace) @@ -173,23 +174,29 @@ def _get_birthplace( return None birthdate_date = _get_date(birthdate) - if not birthdate_date: + issuingdate_date = _get_date(issuingdate) if issuingdate else None + is_foreign = birthplaces_options[0].get("province") == "EE" + if is_foreign and issuingdate_date: + reference_date = issuingdate_date + elif birthdate_date: + reference_date = birthdate_date + else: return cast(dict[str, Any], birthplaces_options[0].copy()) - # search birthplace that has been created before / deleted after birthdate + # search birthplace that has been created before / deleted after reference_date for birthplace_option in birthplaces_options: date_created = _get_date(birthplace_option["date_created"]) or datetime.min date_deleted = _get_date(birthplace_option["date_deleted"]) or datetime.max - # print(birthdate_date, date_created, date_deleted) - if birthdate_date >= date_created and birthdate_date <= date_deleted: + # print(reference_date, date_created, date_deleted) + if reference_date >= date_created and reference_date <= date_deleted: return cast(dict[str, Any], birthplace_option.copy()) - return _get_birthplace_fallback(birthplaces_options, birthdate_date) + return _get_birthplace_fallback(birthplaces_options, reference_date) def _get_birthplace_fallback( birthplaces_options: list[dict[str, Any]], - birthdate_date: datetime, + reference_date: datetime, ) -> dict[str, Any] | None: # avoid wrong birthplace code error when birthdate falls in # missing date-range in the data-source even if birthplace code is valid @@ -201,8 +208,8 @@ def _get_birthplace_fallback( date_deleted = _get_date(birthplace_option["date_deleted"]) date_created = _get_date(birthplace_option_next["date_created"]) if date_deleted and date_created: - if birthdate_date >= date_deleted and date_deleted <= date_created: - # the birthdate is in between a deleted munipality and a created one + if reference_date >= date_deleted and date_deleted <= date_created: + # the reference date is in between a deleted munipality and a created one # if the deleted one has a very short active time delta, # it means that probably the deleted_at value is wrong date_created = _get_date(birthplace_option["date_created"]) @@ -216,12 +223,12 @@ def _get_birthplace_fallback( # Fix issues #210, #213 # sometimes the code has been assigned after date of birth and # the municipality was not yet active at birthdate time, - # let's return the first municipality created after birthdate_date + # let's return the first municipality created after reference_date for index in range(birthplaces_options_count): birthplace_option = birthplaces_options[index] date_created = _get_date(birthplace_option["date_created"]) if date_created: - if birthdate_date <= date_created: + if reference_date <= date_created: return birthplace_option.copy() return None @@ -363,12 +370,19 @@ def encode_birthdate( def encode_birthplace( birthplace: str, birthdate: datetime | str | None = None, + issuingdate: datetime | str | None = None, ) -> str | None: """ Encodes birthplace to the code used in italian fiscal code. :param birthplace: The birthplace :type birthplace: string + :param birthdate: The birthdate + :type birthdate: datetime or string + :param issuingdate: Optional document issuing date; for foreign countries + (province EE) the birthplace code valid at this date is used instead of + the code valid at birthdate + :type issuingdate: datetime or string :returns: The code used in italian fiscal code :rtype: string @@ -376,19 +390,33 @@ def encode_birthplace( if not birthplace: raise ValueError("[codicefiscale] 'birthplace' argument cant be None") + if issuingdate is not None: + issuingdate_date = _get_date(issuingdate) + if not issuingdate_date: + raise ValueError( + "[codicefiscale] 'issuingdate' argument is not a valid date" + ) + birthdate_date = _get_date(birthdate) + if birthdate_date and issuingdate_date < birthdate_date: + raise ValueError( + "[codicefiscale] 'issuingdate' cannot be before 'birthdate'" + ) + birthplace_without_province = re.split(r",|\(", birthplace)[0] birthplace_data = _get_birthplace( birthplace, birthdate, + issuingdate, ) or _get_birthplace( birthplace_without_province, birthdate, + issuingdate, ) if not birthplace_data: raise ValueError( - "[codicefiscale] 'birthplace' / 'birthdate' arguments " - f"({birthplace!r} / {birthdate!r}) not mapped to code" + "[codicefiscale] 'birthplace' / 'birthdate' / 'issuingdate' arguments " + f"({birthplace!r} / {birthdate!r} / {issuingdate!r}) not mapped to code" ) birthplace_code = str(birthplace_data["code"]) @@ -429,6 +457,7 @@ def encode( gender: Literal["m", "M", "f", "F"], birthdate: datetime | str | None, birthplace: str, + issuingdate: datetime | str | None = None, ) -> str: """ Encodes the italian fiscal code. @@ -443,6 +472,10 @@ def encode( :type birthdate: datetime or string :param birthplace: The birthplace :type birthplace: string + :param issuingdate: Optional document issuing date; for foreign countries + (province EE) the birthplace code valid at this date is used instead of + the code valid at birthdate + :type issuingdate: datetime or string :returns: The italian fiscal code :rtype: string @@ -451,7 +484,7 @@ def encode( lastname_code = encode_lastname(lastname) firstname_code = encode_firstname(firstname) birthdate_code = encode_birthdate(birthdate, gender) - birthplace_code = encode_birthplace(birthplace, birthdate) + birthplace_code = encode_birthplace(birthplace, birthdate, issuingdate) code = f"{lastname_code}{firstname_code}{birthdate_code}{birthplace_code}" cin_code = encode_cin(code) code = f"{code}{cin_code}" diff --git a/tests/test_cli.py b/tests/test_cli.py index 2b42adc..a34920a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -56,6 +56,7 @@ def test_encode(): gender="M", birthdate="01/01/1990", birthplace="Roma,RM", + issuingdate=None, subcommand="encode", ) run_with_args(args) @@ -85,16 +86,32 @@ def test_encode_with_wrong_birthplace(): gender="M", birthdate="01/01/1990", birthplace="Romaaa,RM", + issuingdate=None, subcommand="encode", ) run_with_args(args) output = fake_output.getvalue().strip() assert output == ( - "[codicefiscale] 'birthplace' / 'birthdate' arguments " - "('Romaaa,RM' / '01/01/1990') not mapped to code" + "[codicefiscale] 'birthplace' / 'birthdate' / 'issuingdate' arguments " + "('Romaaa,RM' / '01/01/1990' / None) not mapped to code" ) +def test_encode_with_issuingdate_from_command_line(): + assert_command_output( + ( + "python -m codicefiscale encode " + "--firstname Test " + "--lastname Test " + "--gender M " + "--birthdate 20/08/1987 " + "--birthplace Georgia " + "--issuingdate 01/01/2000" + ), + "TSTTST87M20Z254F", + ) + + def test_decode_without_omocodes(): with mock.patch("sys.stdout", new=StringIO()) as fake_output: args = argparse.Namespace( diff --git a/tests/test_encode.py b/tests/test_encode.py index cba5976..f1a1d04 100644 --- a/tests/test_encode.py +++ b/tests/test_encode.py @@ -270,3 +270,52 @@ def test_encode(encode_test_cases): """Test encoding full fiscal codes.""" for case in encode_test_cases: assert codicefiscale.encode(**case["input"]) == case["result"] + + +def test_encode_birthplace_foreign_with_issuingdate(): + """Foreign birthplace code uses issuingdate when provided.""" + birthdate = "20/08/1987" + assert codicefiscale.encode_birthplace("Georgia", birthdate) == "Z136" + assert ( + codicefiscale.encode_birthplace("Georgia", birthdate, "01/01/2000") == "Z254" + ) + + +def test_encode_foreign_with_issuingdate(): + """Full encode uses foreign birthplace code from issuingdate.""" + kwargs = { + "lastname": "Test", + "firstname": "Test", + "gender": "M", + "birthdate": "20/08/1987", + "birthplace": "Georgia", + } + assert codicefiscale.encode(**kwargs) == "TSTTST87M20Z136E" + assert ( + codicefiscale.encode(**kwargs, issuingdate="01/01/2000") + == "TSTTST87M20Z254F" + ) + assert codicefiscale.is_valid("TSTTST87M20Z254F") + decoded = codicefiscale.decode("TSTTST87M20Z254F") + assert decoded["birthplace"]["code"] == "Z254" + + +def test_encode_issuingdate_italy_unchanged(): + """Italian municipalities ignore issuingdate for birthplace lookup.""" + birthdate = "03/04/1985" + birthplace = "Torino" + without = codicefiscale.encode_birthplace(birthplace, birthdate) + with_issuing = codicefiscale.encode_birthplace( + birthplace, birthdate, "01/01/2020" + ) + assert without == with_issuing == "L219" + + +def test_encode_birthplace_issuingdate_invalid(): + """Invalid issuingdate raises ValueError.""" + with pytest.raises(ValueError, match="issuingdate"): + codicefiscale.encode_birthplace("Georgia", "20/08/1987", "not-a-date") + with pytest.raises(ValueError, match="before"): + codicefiscale.encode_birthplace( + "Georgia", "20/08/1987", "01/01/1980" + )