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" + )