Skip to content

Commit

Permalink
fix: upgrade all Finnish SSN related code to support new format
Browse files Browse the repository at this point in the history
refs YJDH-686
  • Loading branch information
karisal-anders committed Jan 11, 2024
1 parent 180be58 commit 490fd61
Show file tree
Hide file tree
Showing 22 changed files with 308 additions and 70 deletions.
2 changes: 1 addition & 1 deletion backend/benefit/requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ pdfkit
pillow
psycopg2
python-dateutil
python-stdnum
python-stdnum>=1.19
pyyaml
requests
sentry-sdk
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1156,6 +1156,16 @@ def test_youth_applications_reactivate_unexpired_inactive__vtj_disabled(
"121212A899H",
"111111-111C",
"111111A111C",
"111111B111C",
"111111C111C",
"111111D111C",
"111111E111C",
"111111F111C",
"111111U111C",
"111111V111C",
"111111W111C",
"111111X111C",
"111111Y111C",
# Django Rest Framework's serializers.CharField trims leading and trailing
# whitespace by default, so we allow them here.
" 111111-111C",
Expand Down Expand Up @@ -1853,6 +1863,13 @@ def test_youth_application_post_empty_required_field(
# Not uppercase
"111111-111c",
"111111a111C",
# Invalid century character
"111111/111C",
"111111G111C",
"111111M111C",
"111111R111C",
"111111T111C",
"111111Z111C",
# Invalid checksum
"111111-111X", # "111111-111C" would be valid
"111111A111W", # "111111A111C" would be valid
Expand Down
2 changes: 1 addition & 1 deletion backend/kesaseteli/common/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ def test_utc_datetime(args):
assert result.tzinfo == timezone.utc


@pytest.mark.parametrize("year", [1800, 2022, 2023, 2099])
@pytest.mark.parametrize("year", [1800, 1987, 2022, 2023, 2024, 2025, 2099])
def test_get_random_social_security_number_for_year(year):
for _ in range(1000):
result = get_random_social_security_number_for_year(year)
Expand Down
1 change: 1 addition & 0 deletions backend/kesaseteli/common/tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def get_random_social_security_number_for_year(year: int) -> str:
tzinfo=timezone.utc,
)
.date(),
century_variant=get_faker().pyint(0, 99), # Inclusive range
individual_number=get_faker().pyint(2, 899), # Inclusive range
)

Expand Down
2 changes: 1 addition & 1 deletion backend/kesaseteli/requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jsonpath-ng
mozilla-django-oidc
pillow
psycopg2
python-stdnum
python-stdnum>=1.19
requests
sentry-sdk
xlsx-streaming
Expand Down
2 changes: 1 addition & 1 deletion backend/kesaseteli/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ python-dateutil==2.8.1
# azure-storage-common
# faker
# pysaml2
python-stdnum==1.17
python-stdnum==1.19
# via
# -r requirements.in
# django-localflavor
Expand Down
130 changes: 101 additions & 29 deletions backend/shared/shared/common/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,28 +97,88 @@ def test_valid_social_security_number_birthdate(test_value, expected_result):


@pytest.mark.parametrize(
"expected_result,birthdate,individual_number",
"expected_result,birthdate,century_variant,individual_number",
[
("010100+002H", date(year=1800, month=1, day=1), 2),
("010203-1230", date(year=1903, month=2, day=1), 123),
("121212A899H", date(year=2012, month=12, day=12), 899),
("111111-002V", date(year=1911, month=11, day=11), 2),
("111111-111C", date(year=1911, month=11, day=11), 111),
("111111A111C", date(year=2011, month=11, day=11), 111),
("111111-900U", date(year=1911, month=11, day=11), 900),
("111111-9991", date(year=1911, month=11, day=11), 999),
("300522A0024", date(year=2022, month=5, day=30), 2),
("311299A999E", date(year=2099, month=12, day=31), 999),
("010100+002H", date(year=1800, month=1, day=1), 0, 2),
("010100+002H", date(year=1800, month=1, day=1), 99, 2),
("010203-1230", date(year=1903, month=2, day=1), 0, 123),
("121212A899H", date(year=2012, month=12, day=12), 0, 899),
("121212A899H", date(year=2012, month=12, day=12), 30, 899),
("121212B899H", date(year=2012, month=12, day=12), 1, 899),
("121212B899H", date(year=2012, month=12, day=12), 31, 899),
("121212C899H", date(year=2012, month=12, day=12), 2, 899),
("121212C899H", date(year=2012, month=12, day=12), 32, 899),
("121212D899H", date(year=2012, month=12, day=12), 3, 899),
("121212D899H", date(year=2012, month=12, day=12), 33, 899),
("121212E899H", date(year=2012, month=12, day=12), 4, 899),
("121212E899H", date(year=2012, month=12, day=12), 34, 899),
("121212F899H", date(year=2012, month=12, day=12), 5, 899),
("121212F899H", date(year=2012, month=12, day=12), 35, 899),
("111111-002V", date(year=1911, month=11, day=11), 0, 2),
("111111-111C", date(year=1911, month=11, day=11), 0, 111),
("111111Y111C", date(year=1911, month=11, day=11), 1, 111),
("111111Y111C", date(year=1911, month=11, day=11), 61, 111),
("111111X111C", date(year=1911, month=11, day=11), 2, 111),
("111111X111C", date(year=1911, month=11, day=11), 62, 111),
("111111W111C", date(year=1911, month=11, day=11), 3, 111),
("111111W111C", date(year=1911, month=11, day=11), 63, 111),
("111111V111C", date(year=1911, month=11, day=11), 4, 111),
("111111V111C", date(year=1911, month=11, day=11), 64, 111),
("111111U111C", date(year=1911, month=11, day=11), 5, 111),
("111111U111C", date(year=1911, month=11, day=11), 65, 111),
("111111A111C", date(year=2011, month=11, day=11), 0, 111),
("111111-900U", date(year=1911, month=11, day=11), 0, 900),
("111111-9991", date(year=1911, month=11, day=11), 0, 999),
("300522A0024", date(year=2022, month=5, day=30), 0, 2),
("300522B0024", date(year=2022, month=5, day=30), 1, 2),
("311299A999E", date(year=2099, month=12, day=31), 0, 999),
("311299F999E", date(year=2099, month=12, day=31), 5, 999),
# Go through all the possible checksum characters & before first & beyond last
("111111B098Y", date(year=2011, month=11, day=11), 1, 98),
("111111B0990", date(year=2011, month=11, day=11), 1, 99),
("111111B1001", date(year=2011, month=11, day=11), 1, 100),
("111111B1012", date(year=2011, month=11, day=11), 1, 101),
("111111B1023", date(year=2011, month=11, day=11), 1, 102),
("111111B1034", date(year=2011, month=11, day=11), 1, 103),
("111111B1045", date(year=2011, month=11, day=11), 1, 104),
("111111B1056", date(year=2011, month=11, day=11), 1, 105),
("111111B1067", date(year=2011, month=11, day=11), 1, 106),
("111111B1078", date(year=2011, month=11, day=11), 1, 107),
("111111B1089", date(year=2011, month=11, day=11), 1, 108),
("111111B109A", date(year=2011, month=11, day=11), 1, 109),
("111111B110B", date(year=2011, month=11, day=11), 1, 110),
("111111B111C", date(year=2011, month=11, day=11), 1, 111),
("111111B112D", date(year=2011, month=11, day=11), 1, 112),
("111111B113E", date(year=2011, month=11, day=11), 1, 113),
("111111B114F", date(year=2011, month=11, day=11), 1, 114),
("111111B115H", date(year=2011, month=11, day=11), 1, 115),
("111111B116J", date(year=2011, month=11, day=11), 1, 116),
("111111B117K", date(year=2011, month=11, day=11), 1, 117),
("111111B118L", date(year=2011, month=11, day=11), 1, 118),
("111111B119M", date(year=2011, month=11, day=11), 1, 119),
("111111B120N", date(year=2011, month=11, day=11), 1, 120),
("111111B121P", date(year=2011, month=11, day=11), 1, 121),
("111111B122R", date(year=2011, month=11, day=11), 1, 122),
("111111B123S", date(year=2011, month=11, day=11), 1, 123),
("111111B124T", date(year=2011, month=11, day=11), 1, 124),
("111111B125U", date(year=2011, month=11, day=11), 1, 125),
("111111B126V", date(year=2011, month=11, day=11), 1, 126),
("111111B127W", date(year=2011, month=11, day=11), 1, 127),
("111111B128X", date(year=2011, month=11, day=11), 1, 128),
("111111B129Y", date(year=2011, month=11, day=11), 1, 129),
("111111B1300", date(year=2011, month=11, day=11), 1, 130),
],
)
def test_valid_create_finnish_social_security_number(
expected_result: str, birthdate: date, individual_number: int
expected_result: str, birthdate: date, century_variant: int, individual_number: int
):
assert validate_finnish_social_security_number(
expected_result, allow_temporary=True
)
assert (
create_finnish_social_security_number(birthdate, individual_number)
create_finnish_social_security_number(
birthdate, century_variant, individual_number
)
== expected_result
)

Expand All @@ -138,31 +198,37 @@ def test_valid_consecutive_create_finnish_social_security_number(birthdate: date
Testing consecutive social security numbers for validity to ensure all their parts
are calculated correctly, including the checksum (i.e. the last character).
"""
for individual_number in range(2, 900):
result = create_finnish_social_security_number(birthdate, individual_number)
assert len(result) == 11
assert validate_finnish_social_security_number(result, allow_temporary=True)
individual_number_from_result: int = int(result[-4:-1])
assert individual_number_from_result == individual_number
assert social_security_number_birthdate(result) == birthdate
for century_variant in range(0, 6):
for individual_number in range(2, 900):
result = create_finnish_social_security_number(
birthdate, century_variant, individual_number
)
assert len(result) == 11
assert validate_finnish_social_security_number(result, allow_temporary=True)
individual_number_from_result: int = int(result[-4:-1])
assert individual_number_from_result == individual_number
assert social_security_number_birthdate(result) == birthdate


@pytest.mark.parametrize(
"birthdate,individual_number",
"birthdate,century_variant,individual_number",
[
(date(year=1799, month=12, day=31), 2), # Birth year < 1800
(date(year=2100, month=1, day=1), 2), # Birth year > 2099
(date(year=2000, month=1, day=1), -1), # Individual number < 2
(date(year=2000, month=1, day=1), 0), # Individual number < 2
(date(year=2000, month=1, day=1), 1), # Individual number < 2
(date(year=2000, month=1, day=1), 1000), # Individual number > 999
(date(year=1799, month=12, day=31), 0, 2), # Birth year < 1800
(date(year=2100, month=1, day=1), 0, 2), # Birth year > 2099
(date(year=2000, month=1, day=1), -1, 0), # Century variant < 0
(date(year=2000, month=1, day=1), 0, -1), # Individual number < 2
(date(year=2000, month=1, day=1), 0, 0), # Individual number < 2
(date(year=2000, month=1, day=1), 0, 1), # Individual number < 2
(date(year=2000, month=1, day=1), 0, 1000), # Individual number > 999
],
)
def test_invalid_create_finnish_social_security_number(
birthdate: date, individual_number: int
birthdate: date, century_variant: int, individual_number: int
):
with pytest.raises(ValueError):
assert create_finnish_social_security_number(birthdate, individual_number)
assert create_finnish_social_security_number(
birthdate, century_variant, individual_number
)


@pytest.mark.django_db
Expand All @@ -175,6 +241,12 @@ def test_invalid_create_finnish_social_security_number(
"111111 -111x", # Invalid checksum, inner whitespace
"320522A002T", # Invalid date because no 32 days in any month
"311322A002E", # Invalid date because no 13 months in any year
"111111/111C", # Invalid century character
"111111G111C", # Invalid century character
"111111M111C", # Invalid century character
"111111R111C", # Invalid century character
"111111T111C", # Invalid century character
"111111Z111C", # Invalid century character
# Invalid checksum
"111111-111X", # "111111-111C" would be valid
"111111A111W", # "111111A111C" would be valid
Expand Down
31 changes: 27 additions & 4 deletions backend/shared/shared/common/tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,54 @@


def create_finnish_social_security_number(
birthdate: date, individual_number: int
birthdate: date, century_variant: int, individual_number: int
) -> str:
"""
Create a Finnish social security number based on birthdate and individual number
Create a Finnish social security number based on birthdate, century variant and
individual number
:param birthdate: Date of birth
:param century_variant: Non-negative integer value for deciding which century
character variant to use, e.g. birthdates in 2000–2099 may
have century character A, B, C, D, E or F. There's no upper
limit as (century_variant % available_century_characters) is
the value being used.
:param individual_number: Integer value where 2 <= individual_number <= 999
:return: Finnish social security number in format <ddmmyyciiis> where
dd = day of birth with leading zeroes,
mm = month of birth with leading zeroes,
yy = year of birth modulo 100 with leading zeroes,
c = century of birth ("+" = 1800, "-" = 1900, "A" = 2000),
c = century of birth, where c is
"+" = 1800,
"-" = 1900 if (century_variant % 6) == 0,
"Y" = 1900 if (century_variant % 6) == 1,
"X" = 1900 if (century_variant % 6) == 2,
"W" = 1900 if (century_variant % 6) == 3,
"V" = 1900 if (century_variant % 6) == 4,
"U" = 1900 if (century_variant % 6) == 5,
"A" = 2000 if (century_variant % 6) == 0,
"B" = 2000 if (century_variant % 6) == 1,
"C" = 2000 if (century_variant % 6) == 2,
"D" = 2000 if (century_variant % 6) == 3,
"E" = 2000 if (century_variant % 6) == 4,
"F" = 2000 if (century_variant % 6) == 5,
iii = individual number in range 2–999 with leading zeroes,
s = checksum value calculated from <ddmmyyiii>.
:raises ValueError: if not (1800 <= birthdate.year <= 2099)
:raises ValueError: if century_variant < 0
:raises ValueError: if not (2 <= individual_number <= 999)
"""
if not (1800 <= birthdate.year <= 2099):
raise ValueError("Invalid birthdate year, only years 1800–2099 are supported")
if century_variant < 0:
raise ValueError("Invalid century variant, must be non-negative")
if not (2 <= individual_number <= 999):
raise ValueError("Invalid individual number, must be in range 2–999")
ddmmyy: str = f"{birthdate.day:02}{birthdate.month:02}{birthdate.year % 100:02}"
iii: str = f"{individual_number:003}"
ddmmyyiii: str = f"{ddmmyy}{iii}"
century_char: str = {18: "+", 19: "-", 20: "A"}[birthdate.year // 100]
century_chars: str = {18: "+", 19: "-YXWVU", 20: "ABCDEF"}[birthdate.year // 100]
century_char: str = century_chars[century_variant % len(century_chars)]
checksum_char: str = "0123456789ABCDEFHJKLMNPRSTUVWXY"[int(ddmmyyiii) % 31]
return f"{ddmmyy}{century_char}{iii}{checksum_char}"

Expand Down
20 changes: 19 additions & 1 deletion backend/shared/shared/common/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,24 @@

_ALWAYS_FALSE_Q_FILTER = Q(pk=None) # A hack but works as primary keys can't be null

# Mapping from Finnish social security number century character to century number.
# Should be the same as stdnum.fi.hetu._century_codes but that dictionary is private.
_CENTURY_CHAR_TO_CENTURY_NUMBER = {
"+": 1800,
"-": 1900,
"A": 2000,
"B": 2000,
"C": 2000,
"D": 2000,
"E": 2000,
"F": 2000,
"U": 1900,
"V": 1900,
"W": 1900,
"X": 1900,
"Y": 1900,
}


def any_of_q_filter(**kwargs):
"""
Expand Down Expand Up @@ -123,5 +141,5 @@ def social_security_number_birthdate(social_security_number) -> date:
month = int(compacted_social_security_number[2:4])
year_mod_100 = int(compacted_social_security_number[4:6])
century_char = compacted_social_security_number[6]
century = {"+": 1800, "-": 1900, "A": 2000}[century_char]
century = _CENTURY_CHAR_TO_CENTURY_NUMBER[century_char]
return date(year=century + year_mod_100, month=month, day=day)
2 changes: 1 addition & 1 deletion backend/tet/requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ filetype
mozilla-django-oidc
pillow
psycopg2
python-stdnum
python-stdnum>=1.19
requests
sentry-sdk
xlsxwriter
2 changes: 1 addition & 1 deletion backend/tet/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ python-dateutil==2.8.1
# pysaml2
python-jose==3.3.0
# via django-helusers
python-stdnum==1.17
python-stdnum==1.19
# via -r requirements.in
pytz==2021.1
# via
Expand Down
2 changes: 1 addition & 1 deletion frontend/benefit/applicant/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"camelcase-keys": "^7.0.2",
"date-fns": "^2.24.0",
"dotenv": "^16.0.0",
"finnish-ssn": "^2.1.1",
"finnish-ssn": "^2.1.2",
"formik": "^2.2.9",
"hds-design-tokens": "^2.17.0",
"hds-react": "^2.17.0",
Expand Down
2 changes: 1 addition & 1 deletion frontend/benefit/handler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"date-fns": "^2.24.0",
"dotenv": "^16.0.0",
"finnish-business-ids": "^3.1.1",
"finnish-ssn": "^2.1.1",
"finnish-ssn": "^2.1.2",
"formik": "^2.2.9",
"fuse.js": "^6.6.2",
"hds-design-tokens": "^2.17.0",
Expand Down
2 changes: 1 addition & 1 deletion frontend/kesaseteli/handler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"@sentry/nextjs": "^7.16.0",
"axios": "^0.27.2",
"dotenv": "^16.0.0",
"finnish-ssn": "2.0.4",
"finnish-ssn": "^2.1.2",
"hds-design-tokens": "^2.17.0",
"hds-react": "^2.17.0",
"lodash": "^4.17.21",
Expand Down
2 changes: 1 addition & 1 deletion frontend/kesaseteli/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"dependencies": {
"@frontend/shared": "*",
"axios": "^0.27.2",
"finnish-ssn": "2.0.4",
"finnish-ssn": "^2.1.2",
"react-query": "^3.34.0"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion frontend/kesaseteli/youth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"@sentry/nextjs": "^7.16.0",
"axios": "^0.27.2",
"dotenv": "^16.0.0",
"finnish-ssn": "2.0.4",
"finnish-ssn": "^2.1.2",
"hds-design-tokens": "^2.17.0",
"hds-react": "^2.17.0",
"lodash": "^4.17.21",
Expand Down
Loading

0 comments on commit 490fd61

Please sign in to comment.