From d1fa5d7bcdc1ccc8ce7451258f07e46cc84a0f2b Mon Sep 17 00:00:00 2001 From: Hongjing <60866283+chenhongjing@users.noreply.github.com> Date: Wed, 15 Nov 2023 15:21:41 -0800 Subject: [PATCH] 18053 Legal API - Validation (AGM Extension) (#2298) * 18053 Signed-off-by: Hongjing Chen * add new line Signed-off-by: Hongjing Chen * update agm validation Signed-off-by: Hongjing Chen * update error msg Signed-off-by: Hongjing Chen * update unit tests Signed-off-by: Hongjing Chen * correct typos Signed-off-by: Hongjing Chen * more validations Signed-off-by: Hongjing Chen * fix linting Signed-off-by: Hongjing Chen * fix linting-2 Signed-off-by: Hongjing Chen * add more validation for required fields Signed-off-by: Hongjing Chen * fix linting Signed-off-by: Hongjing Chen * convert some datetime to date Signed-off-by: Hongjing Chen * update Signed-off-by: Hongjing Chen * fix bugs Signed-off-by: Hongjing Chen * remove validation for intended agm date Signed-off-by: Hongjing Chen * fix partial code smells Signed-off-by: Hongjing Chen --------- Signed-off-by: Hongjing Chen --- legal-api/requirements.txt | 2 +- .../requirements/bcregistry-libraries.txt | 2 +- .../filings/validations/agm_extension.py | 164 ++++++++++++++++++ .../filings/validations/validation.py | 4 + .../legal_api/utils/legislation_datetime.py | 5 + .../filings/validations/test_agm_extension.py | 78 +++++++++ 6 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 legal-api/src/legal_api/services/filings/validations/agm_extension.py create mode 100644 legal-api/tests/unit/services/filings/validations/test_agm_extension.py diff --git a/legal-api/requirements.txt b/legal-api/requirements.txt index 55d4af911d..8c53dae3d5 100755 --- a/legal-api/requirements.txt +++ b/legal-api/requirements.txt @@ -60,5 +60,5 @@ minio==7.0.2 PyPDF2==1.26.0 reportlab==3.6.12 html-sanitizer==1.9.3 -git+https://github.com/bcgov/business-schemas.git@2.18.15#egg=registry_schemas +git+https://github.com/bcgov/business-schemas.git@2.18.16#egg=registry_schemas diff --git a/legal-api/requirements/bcregistry-libraries.txt b/legal-api/requirements/bcregistry-libraries.txt index f20871c80b..7c5614980b 100644 --- a/legal-api/requirements/bcregistry-libraries.txt +++ b/legal-api/requirements/bcregistry-libraries.txt @@ -1 +1 @@ -git+https://github.com/bcgov/business-schemas.git@2.18.15#egg=registry_schemas +git+https://github.com/bcgov/business-schemas.git@2.18.16#egg=registry_schemas diff --git a/legal-api/src/legal_api/services/filings/validations/agm_extension.py b/legal-api/src/legal_api/services/filings/validations/agm_extension.py new file mode 100644 index 0000000000..1f1d20dada --- /dev/null +++ b/legal-api/src/legal_api/services/filings/validations/agm_extension.py @@ -0,0 +1,164 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Validation for the Agm Extension filing.""" +from http import HTTPStatus +from typing import Dict, Optional + +from dateutil.relativedelta import relativedelta +from flask_babel import _ as babel # noqa: N813, I004, I001; importing camelcase '_' as a name +# noqa: I003 +from legal_api.errors import Error +from legal_api.models import Business +from legal_api.utils.legislation_datetime import LegislationDatetime +from ...utils import get_bool, get_int, get_str # noqa: I003 +# noqa: I003 + +AGM_EXTENSION_PATH = '/filing/agmExtension' +EXPIRED_ERROR = 'Allotted period to request extension has expired.' +GRANT_FAILURE = 'Fail to grant extension.' + + +def validate(business: Business, filing: Dict) -> Optional[Error]: + """Validate the AGM Extension filing.""" + if not business or not filing: + return Error(HTTPStatus.BAD_REQUEST, [{'error': babel('A valid business and filing are required.')}]) + + msg = [] + + is_first_agm = get_bool(filing, f'{AGM_EXTENSION_PATH}/isFirstAgm') + + if is_first_agm: + msg.extend(first_agm_validation(business, filing)) + else: + msg.extend(subsequent_agm_validation(filing)) + + if msg: + return Error(HTTPStatus.BAD_REQUEST, msg) + + return None + + +def first_agm_validation(business: Business, filing: Dict) -> list: + """Validate filing for first AGM Extension.""" + msg = [] + + has_ext_req_for_agm_year = get_bool(filing, f'{AGM_EXTENSION_PATH}/extReqForAgmYear') + founding_date = LegislationDatetime.as_legislation_timezone_from_date(business.founding_date).date() + + if not has_ext_req_for_agm_year: + # first AGM, first extension + now = LegislationDatetime.datenow() + latest_ext_date = founding_date + relativedelta(months=18, days=5) + if now > latest_ext_date: + msg.append({'error': EXPIRED_ERROR, + 'path': f'{AGM_EXTENSION_PATH}/isFirstAgm'}) + else: + total_approved_ext = get_int(filing, f'{AGM_EXTENSION_PATH}/totalApprovedExt') + extension_duration = get_int(filing, f'{AGM_EXTENSION_PATH}/extensionDuration') + if total_approved_ext != 6 or extension_duration != 6: + msg.append({'error': babel(GRANT_FAILURE)}) + else: + # first AGM, second extension or more + if not (curr_ext_expire_date_str := get_str(filing, f'{AGM_EXTENSION_PATH}/expireDateCurrExt')): + return [{'error': 'Expiry date for current extension is required.', + 'path': f'{AGM_EXTENSION_PATH}/expireDateCurrExt'}] + + curr_ext_expire_date =\ + LegislationDatetime.as_legislation_timezone_from_date_str(curr_ext_expire_date_str).date() + allowable_ext_date = founding_date + relativedelta(months=30) + now = LegislationDatetime.datenow() + if curr_ext_expire_date >= allowable_ext_date: + msg.append({'error': 'Company has received the maximum 12 months of allowable extensions.', + 'path': f'{AGM_EXTENSION_PATH}/expireDateCurrExt'}) + elif now > curr_ext_expire_date + relativedelta(days=5): + msg.append({'error': EXPIRED_ERROR, + 'path': f'{AGM_EXTENSION_PATH}/expireDateCurrExt'}) + else: + total_approved_ext = get_int(filing, f'{AGM_EXTENSION_PATH}/totalApprovedExt') + extension_duration = get_int(filing, f'{AGM_EXTENSION_PATH}/extensionDuration') + + baseline = founding_date + relativedelta(months=18) + expected_total_approved_ext, expected_extension_duration =\ + _calculate_granted_ext(curr_ext_expire_date, baseline) + + if expected_total_approved_ext != total_approved_ext or\ + expected_extension_duration != extension_duration: + msg.append({'error': babel(GRANT_FAILURE)}) + + return msg + + +def subsequent_agm_validation(filing: Dict) -> list: + """Validate filing for subsequent AGM Extension.""" + msg = [] + + has_ext_req_for_agm_year = filing['filing']['agmExtension']['extReqForAgmYear'] + if not (prev_agm_ref_date_str := get_str(filing, f'{AGM_EXTENSION_PATH}/prevAgmRefDate')): + return [{'error': 'Previous AGM date or a reference date is required.', + 'path': f'{AGM_EXTENSION_PATH}/prevAgmRefDate'}] + + prev_agm_ref_date =\ + LegislationDatetime.as_legislation_timezone_from_date_str(prev_agm_ref_date_str).date() + + if not has_ext_req_for_agm_year: + # subsequent AGM, first extension + now = LegislationDatetime.datenow() + latest_ext_date = prev_agm_ref_date + relativedelta(months=15, days=5) + if now > latest_ext_date: + msg.append({'error': EXPIRED_ERROR, + 'path': f'{AGM_EXTENSION_PATH}/prevAgmRefDate'}) + else: + total_approved_ext = get_int(filing, f'{AGM_EXTENSION_PATH}/totalApprovedExt') + extension_duration = get_int(filing, f'{AGM_EXTENSION_PATH}/extensionDuration') + if total_approved_ext != 6 or extension_duration != 6: + msg.append({'error': babel(GRANT_FAILURE)}) + else: + # subsequent AGM, second extension or more + if not (curr_ext_expire_date_str := get_str(filing, f'{AGM_EXTENSION_PATH}/expireDateCurrExt')): + return [{'error': 'Expiry date for current extension is required.', + 'path': f'{AGM_EXTENSION_PATH}/expireDateCurrExt'}] + + curr_ext_expire_date =\ + LegislationDatetime.as_legislation_timezone_from_date_str(curr_ext_expire_date_str).date() + + allowable_ext_date = prev_agm_ref_date + relativedelta(months=12) + now = LegislationDatetime.datenow() + + if curr_ext_expire_date >= allowable_ext_date: + msg.append({'error': 'Company has received the maximum 12 months of allowable extensions.', + 'path': f'{AGM_EXTENSION_PATH}/expireDateCurrExt'}) + elif now > curr_ext_expire_date + relativedelta(days=5): + msg.append({'error': EXPIRED_ERROR, + 'path': f'{AGM_EXTENSION_PATH}/expireDateCurrExt'}) + else: + total_approved_ext = get_int(filing, f'{AGM_EXTENSION_PATH}/totalApprovedExt') + extension_duration = get_int(filing, f'{AGM_EXTENSION_PATH}/extensionDuration') + + expected_total_approved_ext, expected_extension_duration =\ + _calculate_granted_ext(curr_ext_expire_date, prev_agm_ref_date) + + if expected_total_approved_ext != total_approved_ext or\ + expected_extension_duration != extension_duration: + msg.append({'error': babel(GRANT_FAILURE)}) + + return msg + + +def _calculate_granted_ext(curr_ext_expire_date, baseline_date) -> tuple: + """Calculate expected total approved extension and extension duration.""" + total_approved_ext = relativedelta(curr_ext_expire_date, baseline_date).months + extension_duration = min(12-total_approved_ext, 6) + total_approved_ext += extension_duration + + return total_approved_ext, extension_duration diff --git a/legal-api/src/legal_api/services/filings/validations/validation.py b/legal-api/src/legal_api/services/filings/validations/validation.py index ea6967af78..1aa20aaf4a 100644 --- a/legal-api/src/legal_api/services/filings/validations/validation.py +++ b/legal-api/src/legal_api/services/filings/validations/validation.py @@ -22,6 +22,7 @@ from legal_api.services.utils import get_str from .admin_freeze import validate as admin_freeze_validate +from .agm_extension import validate as agm_extension_validate from .agm_location_change import validate as agm_location_change_validate from .alteration import validate as alteration_validate from .annual_report import validate as annual_report_validate @@ -183,6 +184,9 @@ def validate(business: Business, filing_json: Dict) -> Error: # pylint: disable elif k == Filing.FILINGS['agmLocationChange'].get('name'): err = agm_location_change_validate(business, filing_json) + elif k == Filing.FILINGS['agmExtension'].get('name'): + err = agm_extension_validate(business, filing_json) + if err: return err diff --git a/legal-api/src/legal_api/utils/legislation_datetime.py b/legal-api/src/legal_api/utils/legislation_datetime.py index d3ab27f952..9e7407e760 100644 --- a/legal-api/src/legal_api/utils/legislation_datetime.py +++ b/legal-api/src/legal_api/utils/legislation_datetime.py @@ -28,6 +28,11 @@ def now() -> datetime: """Construct a datetime using the legislation timezone.""" return datetime.now().astimezone(pytz.timezone(current_app.config.get('LEGISLATIVE_TIMEZONE'))) + @staticmethod + def datenow() -> date: + """Construct a date using the legislation timezone.""" + return LegislationDatetime.now().date() + @staticmethod def tomorrow_midnight() -> datetime: """Construct a datetime tomorrow midnight using the legislation timezone.""" diff --git a/legal-api/tests/unit/services/filings/validations/test_agm_extension.py b/legal-api/tests/unit/services/filings/validations/test_agm_extension.py new file mode 100644 index 0000000000..45772e60cd --- /dev/null +++ b/legal-api/tests/unit/services/filings/validations/test_agm_extension.py @@ -0,0 +1,78 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Test suite to ensure AGM Extension is validated correctly.""" +import copy +from dateutil.relativedelta import relativedelta +from http import HTTPStatus +from unittest.mock import patch + +import pytest +from registry_schemas.example_data import FILING_HEADER + +from legal_api.services.filings.validations.validation import validate +from legal_api.utils.legislation_datetime import LegislationDatetime +from legal_api.utils.datetime import datetime + +from tests.unit.models import factory_business + + +@pytest.mark.parametrize( + 'test_name, founding_date, agm_ext_json, expected_code, message', + [ + ('SUCCESS_FIRST_AGM_FIRST_EXT', '2023-10-01', + {'year': '2023','isFirstAgm': True, 'extReqForAgmYear': False, 'totalApprovedExt': 6, 'extensionDuration': 6}, + None, None), + ('FAIL_FIRST_AGM_FIRST_EXT_TOO_LATE', '2020-10-01', {'year': '2023','isFirstAgm': True, 'extReqForAgmYear': False}, + HTTPStatus.BAD_REQUEST, 'Allotted period to request extension has expired.'), + ('SUCCESS_FIRST_AGM_MORE_EXT', '2022-10-01', + {'year': '2023','isFirstAgm': True, 'extReqForAgmYear': True, 'expireDateCurrExt': '2024-10-01', 'totalApprovedExt': 12, 'extensionDuration': 6}, + None, None), + ('FAIL_FIRST_AGM_MORE_EXT_TOO_LATE', '2022-10-01', {'year': '2023','isFirstAgm': True, 'extReqForAgmYear': True, 'expireDateCurrExt': '2023-12-01'}, + HTTPStatus.BAD_REQUEST, 'Allotted period to request extension has expired.'), + ('FAIL_FIRST_AGM_MORE_EXT_EXCEED_LIMIT', '2021-10-01', {'year': '2023','isFirstAgm': True, 'extReqForAgmYear': True, 'expireDateCurrExt': '2024-10-01'}, + HTTPStatus.BAD_REQUEST, 'Company has received the maximum 12 months of allowable extensions.'), + ('SUCCESS_SUBSEQUENT_AGM_FIRST_EXT', '2020-10-01', + {'year': '2023','isFirstAgm': False, 'extReqForAgmYear': False, 'prevAgmRefDate':'2023-10-01', 'totalApprovedExt': 6, 'extensionDuration': 6}, + None, None), + ('FAIL_SUBSEQUENT_AGM_FIRST_EXT_TOO_LATE', '2020-10-01', {'year': '2023','isFirstAgm': False, 'extReqForAgmYear': False, 'prevAgmRefDate':'2022-06-01'}, + HTTPStatus.BAD_REQUEST, 'Allotted period to request extension has expired.'), + ('SUCCESS_SUBSEQUENT_AGM_MORE_EXT', '2022-10-01', + {'year': '2023','isFirstAgm': False, 'extReqForAgmYear': True, 'prevAgmRefDate':'2023-06-01', 'expireDateCurrExt':'2024-05-01', 'totalApprovedExt': 12, 'extensionDuration': 1}, + None, None), + ('FAIL_SUBSEQUENT_AGM_MORE_EXT_TOO_LATE', '2022-10-01', + {'year': '2023','isFirstAgm': False, 'extReqForAgmYear': True, 'prevAgmRefDate':'2023-06-01', 'expireDateCurrExt':'2023-12-01'}, + HTTPStatus.BAD_REQUEST, 'Allotted period to request extension has expired.'), + ('FAIL_SUBSEQUENT_AGM_MORE_EXT_EXCEED_LIMIT', '2022-10-01', + {'year': '2023','isFirstAgm': False, 'extReqForAgmYear': True, 'prevAgmRefDate':'2023-06-01', 'expireDateCurrExt':'2024-06-01'}, + HTTPStatus.BAD_REQUEST, 'Company has received the maximum 12 months of allowable extensions.') + ] +) +def test_validate_agm_extension(session, mocker, test_name, founding_date, agm_ext_json, expected_code, message): + """Assert validate AGM extension.""" + business = factory_business( + identifier='BC1234567', entity_type='BC', founding_date=LegislationDatetime.as_legislation_timezone_from_date_str(founding_date) + ) + filing = copy.deepcopy(FILING_HEADER) + filing['filing']['agmExtension'] = agm_ext_json + filing['filing']['header']['name'] = 'agmExtension' + + with patch.object(LegislationDatetime, 'now', return_value=LegislationDatetime.as_legislation_timezone_from_date_str('2024-01-01')): + err = validate(business, filing) + + if not test_name.startswith('SUCCESS'): + assert expected_code == err.code + if message: + assert message == err.msg[0]['error'] + else: + assert not err