diff --git a/legal-api/poetry.lock b/legal-api/poetry.lock index 1fb69579a2..2dc9af98f4 100644 --- a/legal-api/poetry.lock +++ b/legal-api/poetry.lock @@ -2165,7 +2165,7 @@ typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} [[package]] name = "registry_schemas" -version = "2.18.59" +version = "2.18.62" description = "A short description of the project" optional = false python-versions = ">=3.6" @@ -2183,8 +2183,8 @@ strict-rfc3339 = "*" [package.source] type = "git" url = "https://github.com/bcgov/business-schemas.git" -reference = "2.18.60" -resolved_reference = "4e355909b8d16237421bf7eb391acd23e037d437" +reference = "2.18.62" +resolved_reference = "1cea81cf14104fb7d4989169236eff13b3df16c4" [[package]] name = "reportlab" @@ -3086,4 +3086,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9.22,<3.10" -content-hash = "c256ff2667772a27ed6a47b64355b104f197a99bafe2b441679263fba0674bf4" +content-hash = "bbb350d1d3081b4743035ac827680e44de266ed4150b31e115f423ce492d9674" diff --git a/legal-api/pyproject.toml b/legal-api/pyproject.toml index 0894515d07..0595c9a609 100644 --- a/legal-api/pyproject.toml +++ b/legal-api/pyproject.toml @@ -52,7 +52,7 @@ dependencies = [ "blinker (==1.4)", "pyjwt (==2.8.0)", - "registry_schemas @ git+https://github.com/bcgov/business-schemas.git@2.18.60#egg=registry_schemas", + "registry_schemas @ git+https://github.com/bcgov/business-schemas.git@2.18.62#egg=registry_schemas", "sql-versioning @ git+https://github.com/bcgov/lear.git@main#subdirectory=python/common/sql-versioning", "gcp-queue @ git+https://github.com/bcgov/sbc-connect-common.git@main#subdirectory=python/gcp-queue", "structured-logging @ git+https://github.com/bcgov/sbc-connect-common.git@main#subdirectory=python/structured-logging" diff --git a/legal-api/src/legal_api/services/filings/validations/alteration.py b/legal-api/src/legal_api/services/filings/validations/alteration.py index a806251fa4..c410d9ea29 100644 --- a/legal-api/src/legal_api/services/filings/validations/alteration.py +++ b/legal-api/src/legal_api/services/filings/validations/alteration.py @@ -53,8 +53,8 @@ def validate(business: Business, filing: dict) -> Error: # pylint: disable=too- msg.extend(rules_change_validation(filing)) msg.extend(memorandum_change_validation(filing)) - if err := validate_resolution_date_in_share_structure(filing, "alteration"): - msg.append(err) + if err := validate_resolution_date_in_share_structure(filing, "alteration", business): + msg.extend(err) new_legal_type = filing["filing"]["alteration"].get("business", {}).get("legalType", None) err = validate_phone_number(filing, new_legal_type or business.legal_type, "alteration") diff --git a/legal-api/src/legal_api/services/filings/validations/common_validations.py b/legal-api/src/legal_api/services/filings/validations/common_validations.py index 3a2f0db9d2..e6a021d90a 100644 --- a/legal-api/src/legal_api/services/filings/validations/common_validations.py +++ b/legal-api/src/legal_api/services/filings/validations/common_validations.py @@ -32,7 +32,9 @@ from legal_api.services.permissions import ListActionsPermissionsAllowed, PermissionService from legal_api.services.request_context import get_request_context from legal_api.services.utils import get_str +from legal_api.utils.datetime import date from legal_api.utils.datetime import datetime as dt +from legal_api.utils.legislation_datetime import LegislationDatetime NO_POSTAL_CODE_COUNTRY_CODES = { "AO", "AG", "AW", "BS", "BZ", "BJ", "BM", "BO", "BQ", "BW", "BF", "BI", @@ -58,22 +60,58 @@ MAX_SHARE_DIGITS = 16 -def validate_resolution_date_in_share_structure(filing_json, filing_type) -> Optional[dict]: - """Has resolution date in share structure when hasRightsOrRestrictions is true.""" +def validate_resolution_date_in_share_structure(filing_json, filing_type, business) -> list[dict]: + """Validate the resolution date of a share structure. + + Rules: + - If hasRightsOrRestrictions is true in any share class or series, resolution date is required. + - Only one resolution date is permitted. + - Resolution date cannot be in the future. + - Resolution date cannot be before the business founding date. + """ share_structure = filing_json["filing"][filing_type].get("shareStructure", {}) share_classes = share_structure.get("shareClasses", []) + resolution_dates = share_structure.get("resolutionDates", []) + + err_path = f"/filing/{filing_type}/shareStructure/resolutionDates" + + msg = [] if ( ( any(x.get("hasRightsOrRestrictions", False) for x in share_classes) or any(has_rights_or_restrictions_true_in_share_series(x) for x in share_classes) ) and - len(share_structure.get("resolutionDates", [])) == 0 + len(resolution_dates) == 0 ): - return { - "error": "Resolution date is required when hasRightsOrRestrictions is true in shareClasses.", - "path": f"/filing/{filing_type}/shareStructure/resolutionDates" - } - return None + msg.append({ + "error": "Resolution date is required when hasRightsOrRestrictions is true.", + "path": err_path + }) + + if len(resolution_dates) > 1: + msg.append({ + "error": "Only one resolution date is permitted.", + "path": err_path + }) + + elif len(resolution_dates) == 1: + resolution_date_leg = date.fromisoformat(resolution_dates[0]) + founding_date_leg = LegislationDatetime.as_legislation_timezone(business.founding_date).date() + today_leg = LegislationDatetime.datenow() + + if resolution_date_leg > today_leg: + msg.append({ + "error": "Resolution date cannot be in the future.", + "path": err_path + }) + + if resolution_date_leg < founding_date_leg: + msg.append({ + "error": "Resolution date cannot be before the business founding date.", + "path": err_path + }) + + return msg def has_rights_or_restrictions_true_in_share_series(share_class) -> bool: diff --git a/legal-api/src/legal_api/services/filings/validations/correction.py b/legal-api/src/legal_api/services/filings/validations/correction.py index 829917bd92..951a35b43d 100644 --- a/legal-api/src/legal_api/services/filings/validations/correction.py +++ b/legal-api/src/legal_api/services/filings/validations/correction.py @@ -30,6 +30,7 @@ validate_parties_addresses, validate_parties_names, validate_pdf, + validate_resolution_date_in_share_structure, validate_share_structure, ) from legal_api.services.filings.validations.incorporation_application import ( @@ -84,7 +85,7 @@ def validate(business: Business, filing: dict) -> Error: if business.legal_type in [Business.LegalTypes.SOLE_PROP.value, Business.LegalTypes.PARTNERSHIP.value]: _validate_firms_correction(business, filing, business.legal_type, msg) elif business.legal_type in Business.CORPS: - _validate_corps_correction(filing, business.legal_type, msg) + _validate_corps_correction(business, filing, business.legal_type, msg) elif business.legal_type == Business.LegalTypes.COOP.value: _validate_special_resolution_correction(filing, business.legal_type, msg) @@ -107,7 +108,7 @@ def _validate_firms_correction(business: Business, filing, legal_type, msg): msg.extend(validate_naics(business, filing, filing_type)) -def _validate_corps_correction(filing_dict, legal_type, msg): +def _validate_corps_correction(business: Business, filing_dict, legal_type, msg): filing_type = "correction" if filing_dict.get("filing", {}).get("correction", {}).get("nameRequest", {}).get("nrNumber", None): msg.extend(validate_name_request(filing_dict, legal_type, filing_type)) @@ -124,6 +125,10 @@ def _validate_corps_correction(filing_dict, legal_type, msg): if err: msg.extend(err) + err = validate_resolution_date_in_share_structure(filing_dict, filing_type, business) + if err: + msg.extend(err) + def _validate_special_resolution_correction(filing_dict, legal_type, msg): filing_type = "correction" diff --git a/legal-api/tests/unit/services/filings/validations/test_alteration.py b/legal-api/tests/unit/services/filings/validations/test_alteration.py index 9ec8322725..6c325710c8 100644 --- a/legal-api/tests/unit/services/filings/validations/test_alteration.py +++ b/legal-api/tests/unit/services/filings/validations/test_alteration.py @@ -15,6 +15,7 @@ import copy from http import HTTPStatus from unittest.mock import MagicMock, create_autospec, patch +import datedelta from datetime import date from flask import g from legal_api.errors import Error @@ -28,6 +29,7 @@ from legal_api.models import Business from legal_api.services import flags, NameXService from legal_api.services.filings import validate +from legal_api.utils.datetime import datetime, timezone from tests.unit.models import factory_business from tests.unit.services.filings.test_utils import _upload_file from tests.unit.services.filings.validations import lists_are_equal @@ -392,48 +394,79 @@ def test_validate_nr_type(mock_get_parties, session, new_name, legal_type, nr_le assert HTTPStatus.BAD_REQUEST == err.code assert err.msg[0]['error'] == err_msg +NOW = datetime(2025, 1, 1, 12, 0, tzinfo=timezone.utc) +FOUNDING_DATE = NOW - datedelta.YEAR @pytest.mark.parametrize( - 'test_name, should_pass, has_rights_or_restrictions, has_rights_or_restrictions_series, resolution_dates', [ - ('SUCCESS_has_rights_or_restrictions', True, True, False, ['2020-05-23']), - ('SUCCESS', True, False, False, []), - ('FAILURE', False, True, False, []), - ('SUCCESS_series_has_rights_or_restrictions', True, False, True, ['2020-05-23']), - ('SUCCESS_series', True, False, False, []), - ('FAILURE_series', False, False, True, []) - ]) + 'test_name, has_rights_or_restrictions, has_rights_or_restrictions_series, ' + 'resolution_dates, expected_code, expected_msg', + [ + ('SUCCESS_class_has_rights', True, False, ['2024-01-01'], None, None), + ('SUCCESS_class_no_rights', False, False, [], None, None), + ('SUCCESS_series_has_rights', False, True, ['2024-01-01'], None, None), + ('SUCCESS_series_no_rights', False, False, [], None, None), + + ('FAILURE_class_missing_date', True, False, [], HTTPStatus.BAD_REQUEST, [ + {'error': 'Resolution date is required when hasRightsOrRestrictions is true.', + 'path': '/filing/alteration/shareStructure/resolutionDates'} + ]), + ('FAILURE_series_missing_date', False, True, [], HTTPStatus.BAD_REQUEST, [ + {'error': 'Resolution date is required when hasRightsOrRestrictions is true.', + 'path': '/filing/alteration/shareStructure/resolutionDates'} + ]), + + ('FAILURE_too_many_dates', True, False, ['2024-01-01', '2024-02-01'], HTTPStatus.BAD_REQUEST, [ + {'error': 'Only one resolution date is permitted.', + 'path': '/filing/alteration/shareStructure/resolutionDates'} + ]), + + ('FAILURE_future_date', True, False, [(NOW + datedelta.DAY).date().isoformat()], HTTPStatus.BAD_REQUEST, [ + {'error': 'Resolution date cannot be in the future.', + 'path': '/filing/alteration/shareStructure/resolutionDates'} + ]), + + ('FAILURE_before_founding', True, False, [(FOUNDING_DATE - datedelta.DAY).date().isoformat()], HTTPStatus.BAD_REQUEST, [ + {'error': 'Resolution date cannot be before the business founding date.', + 'path': '/filing/alteration/shareStructure/resolutionDates'} + ]), + ] +) @patch.object(PermissionService, 'check_user_permission', MagicMock(return_value=None)) def test_alteration_resolution_date( - session, test_name, should_pass, has_rights_or_restrictions, - has_rights_or_restrictions_series, resolution_dates): - """Test resolution date in share structure.""" + session, test_name, has_rights_or_restrictions, + has_rights_or_restrictions_series, resolution_dates, expected_code, expected_msg): + """Test resolution date validation in alteration share structure.""" # setup identifier = 'BC1234567' business = factory_business(identifier) + business.founding_date = FOUNDING_DATE f = copy.deepcopy(ALTERATION_FILING_TEMPLATE) f['filing']['header']['identifier'] = identifier del f['filing']['alteration']['nameRequest'] del f['filing']['alteration']['business']['legalType'] + # set rights/restrictions and resolution dates f['filing']['alteration']['shareStructure']['shareClasses'][0]['hasRightsOrRestrictions'] = \ has_rights_or_restrictions f['filing']['alteration']['shareStructure']['shareClasses'][0]['series'][0]['hasRightsOrRestrictions'] = \ has_rights_or_restrictions_series f['filing']['alteration']['shareStructure']['resolutionDates'] = resolution_dates - err = validate(business, f) + if 'courtOrder' in f['filing']['alteration']: + del f['filing']['alteration']['courtOrder'] - if err: - print(err.msg) + # freeze time to ensure deterministic validation + with freeze_time(NOW): + err = validate(business, f) - if should_pass: - # check that validation passed - assert None is err + # validate outcomes + if expected_code: + assert err.code == expected_code + assert lists_are_equal(err.msg, expected_msg) else: - # check that validation failed - assert err - assert HTTPStatus.BAD_REQUEST == err.code + assert err is None + @patch.object(PermissionService, 'check_user_permission', MagicMock(return_value=None)) def test_alteration_share_classes_optional(session): diff --git a/legal-api/tests/unit/services/filings/validations/test_correction_ia.py b/legal-api/tests/unit/services/filings/validations/test_correction_ia.py index 2b7738a9e0..17c2b1dcab 100644 --- a/legal-api/tests/unit/services/filings/validations/test_correction_ia.py +++ b/legal-api/tests/unit/services/filings/validations/test_correction_ia.py @@ -14,6 +14,9 @@ """Test Correction IA validations.""" import copy +import datedelta +from datetime import datetime, timezone +from freezegun import freeze_time from http import HTTPStatus from unittest.mock import patch @@ -304,3 +307,84 @@ def test_correction_share_class_series_validation(mocker, session, legal_type, h else: assert err assert any('cannot have series when hasRightsOrRestrictions is false' in msg['error'] for msg in err.msg) + +NOW = datetime(2025, 1, 1, 12, 0, tzinfo=timezone.utc) +FOUNDING_DATE = NOW - datedelta.YEAR + +@pytest.mark.parametrize( + 'test_name, has_rights_or_restrictions, has_series, resolution_dates, expected_code, expected_msg', + [ + ('SUCCESS_class_has_rights', True, False, ['2024-01-01'], None, None), + ('SUCCESS_class_no_rights', False, False, [], None, None), + ('SUCCESS_series_has_rights', True, True, ['2024-01-01'], None, None), + ('SUCCESS_series_no_rights', False, False, [], None, None), + + ('FAILURE_class_missing_date', True, False, [], HTTPStatus.BAD_REQUEST, [ + {'error': 'Resolution date is required when hasRightsOrRestrictions is true.', + 'path': '/filing/correction/shareStructure/resolutionDates'} + ]), + ('FAILURE_series_missing_date', False, True, [], HTTPStatus.BAD_REQUEST, [ + {'error': 'Resolution date is required when hasRightsOrRestrictions is true.', + 'path': '/filing/correction/shareStructure/resolutionDates'} + ]), + + ('FAILURE_too_many_dates', True, False, ['2024-01-01', '2024-02-01'], HTTPStatus.BAD_REQUEST, [ + {'error': 'Only one resolution date is permitted.', + 'path': '/filing/correction/shareStructure/resolutionDates'} + ]), + + ('FAILURE_future_date', True, False, [(NOW + datedelta.DAY).date().isoformat()], HTTPStatus.BAD_REQUEST, [ + {'error': 'Resolution date cannot be in the future.', + 'path': '/filing/correction/shareStructure/resolutionDates'} + ]), + + ('FAILURE_before_founding', True, False, [(FOUNDING_DATE - datedelta.DAY).date().isoformat()], HTTPStatus.BAD_REQUEST, [ + {'error': 'Resolution date cannot be before the business founding date.', + 'path': '/filing/correction/shareStructure/resolutionDates'} + ]), + ] +) +def test_correction_resolution_date(mocker, session, test_name, has_rights_or_restrictions, + has_series, resolution_dates, expected_code, expected_msg): + """Test share class/series resolution date validation in correction filings.""" + mocker.patch('legal_api.utils.auth.jwt.validate_roles', return_value=False) + identifier = 'BC1234567' + business = factory_business(identifier, entity_type='BC') + business.founding_date = FOUNDING_DATE + + corrected_filing = factory_completed_filing(business, INCORPORATION_APPLICATION) + + filing = copy.deepcopy(CORRECTION) + filing['filing']['header']['identifier'] = identifier + filing['filing']['correction']['correctedFilingId'] = corrected_filing.id + del filing['filing']['correction']['commentOnly'] + + # Share structure setup + filing['filing']['correction']['shareStructure'] = copy.deepcopy( + INCORPORATION_FILING_TEMPLATE['filing']['incorporationApplication'].get('shareStructure', {}) + ) + share_class = filing['filing']['correction']['shareStructure']['shareClasses'][0] + share_class['hasRightsOrRestrictions'] = has_rights_or_restrictions + + # Series handling + if has_series: + share_class['series'] = share_class.get('series', [{}]) + share_class['series'][0]['hasRightsOrRestrictions'] = True + else: + share_class.pop('series', None) + + filing['filing']['correction']['shareStructure']['resolutionDates'] = resolution_dates + + # Remove the second share class if it exists + share_classes = filing['filing']['correction']['shareStructure']['shareClasses'] + if len(share_classes) > 1: + share_classes.pop(1) + + with freeze_time(NOW): + err = validate(business, filing) + + if expected_code: + assert err + assert any(expected_msg[0]['error'] in e['error'] for e in err.msg) + else: + assert err is None diff --git a/queue_services/business-filer/pyproject.toml b/queue_services/business-filer/pyproject.toml index dd78267bee..042fecd04a 100644 --- a/queue_services/business-filer/pyproject.toml +++ b/queue_services/business-filer/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "business-filer" -version = "3.0.9" +version = "3.0.10" description = "Business Registry Filer Service" authors = [ {name = "thor",email = "1042854+thorwolpert@users.noreply.github.com"} diff --git a/queue_services/business-filer/src/business_filer/filing_processors/filing_components/shares.py b/queue_services/business-filer/src/business_filer/filing_processors/filing_components/shares.py index be1d4717e3..ef41cf5a47 100644 --- a/queue_services/business-filer/src/business_filer/filing_processors/filing_components/shares.py +++ b/queue_services/business-filer/src/business_filer/filing_processors/filing_components/shares.py @@ -38,13 +38,8 @@ from dateutil.parser import parse -def update_share_structure(business: Business, share_structure: dict) -> list | None: - """Manage the share structure for a business. - - Assumption: The structure has already been validated, upon submission. - - Other errors are recorded and will be managed out of band. - """ +def update_resolution_dates(business: Business, share_structure: dict) -> list | None: + """Update the business resolution dates from a share structure.""" if not business or not share_structure: # if nothing is passed in, we don't care and it's not an error return None @@ -65,6 +60,19 @@ def update_share_structure(business: Business, share_structure: dict) -> list | "error_message": f"Filer: invalid resolution date:'{resolution_dt}'"} ) + return err + +def update_share_structure(business: Business, share_structure: dict) -> list | None: + """Manage the share structure for a business. + + Assumption: The structure has already been validated, upon submission. + + Other errors are recorded and will be managed out of band. + """ + err = [] + + err = update_resolution_dates(business, share_structure) + if share_classes := share_structure.get("shareClasses"): try: delete_existing_shares(business) @@ -88,50 +96,6 @@ def update_share_structure(business: Business, share_structure: dict) -> list | return err - -def update_resolution_dates_correction(business: Business, share_structure: dict) -> list: - """Correct resolution dates by adding or removing.""" - err = [] - - inclusion_entries = [] - exclusion_entries = [] - # Delete the ones that are present in db but not in the json and create the ones in json but not in db. - if resolution_dates := share_structure.get("resolutionDates"): - # Two lists of dates in datetime format - business_dates = [item.resolution_date for item in business.resolutions] - parsed_dates = [parse(resolution_dt).date() for resolution_dt in resolution_dates] - - # Dates in both db and json - inclusion_entries = [business.resolutions[index] for index, date in enumerate(business_dates) - if date in parsed_dates] - if len(inclusion_entries) > 0: - business.resolutions = inclusion_entries - else: - business.resolutions = [] - - # Dates in json and not in db - exclusion_entries = [date for date in parsed_dates if date not in business_dates] - - resolution_dates = exclusion_entries - - for resolution_dt in resolution_dates: - try: - d = Resolution( - resolution_date=resolution_dt, - resolution_type=Resolution.ResolutionType.SPECIAL.value - ) - business.resolutions.append(d) - except (ValueError, OverflowError): - err.append( - {"error_code": "FILER_INVALID_RESOLUTION_DATE", - "error_message": f"Filer: invalid resolution date:'{resolution_dt}'"} - ) - else: - business.resolutions = [] - - return err - - def update_share_structure_correction(business: Business, share_structure: dict) -> list | None: """Manage the share structure for a business. @@ -143,7 +107,7 @@ def update_share_structure_correction(business: Business, share_structure: dict) # if nothing is passed in, we don't care and it's not an error return None - err = update_resolution_dates_correction(business, share_structure) + err = update_resolution_dates(business, share_structure) if share_classes := share_structure.get("shareClasses"): # Entries in json and not in db diff --git a/queue_services/business-filer/tests/unit/test_filer/test_correction_bcia.py b/queue_services/business-filer/tests/unit/test_filer/test_correction_bcia.py index f7217a3f87..725515ad67 100644 --- a/queue_services/business-filer/tests/unit/test_filer/test_correction_bcia.py +++ b/queue_services/business-filer/tests/unit/test_filer/test_correction_bcia.py @@ -700,31 +700,31 @@ def tests_filer_resolution_dates_change(app, session, mocker, test_name, legal_t resolution_dates = [res.resolution_date for res in business.resolutions.all()] if 'add_resolution_dates' in test_name: - assert len(resolution_dates) == 4 assert parse(existing_resolution_date).date() in resolution_dates assert parse(resolution_dates_json1).date() in resolution_dates assert parse(resolution_dates_json2).date() in resolution_dates assert parse(new_resolution_dates).date() in resolution_dates elif 'update_existing_resolution_dates' in test_name: - assert len(resolution_dates) == 3 assert parse(resolution_dates_json1).date() in resolution_dates assert parse(updated_resolution_dates).date() in resolution_dates - assert parse(existing_resolution_date).date() not in resolution_dates + # existing date should NOT be removed in correction + assert parse(existing_resolution_date).date() in resolution_dates elif 'update_with_new_resolution_dates' in test_name: - assert len(resolution_dates) == 1 assert parse(updated_resolution_dates).date() in resolution_dates - assert parse(resolution_dates_json1).date() not in resolution_dates - assert parse(resolution_dates_json2).date() not in resolution_dates + # history preserved + assert parse(existing_resolution_date).date() in resolution_dates elif 'delete_resolution_dates' in test_name: - assert len(resolution_dates) == 2 + # corrections should not delete historical resolution dates + assert parse(existing_resolution_date).date() in resolution_dates assert parse(resolution_dates_json1).date() in resolution_dates assert parse(resolution_dates_json2).date() in resolution_dates elif 'delete_all_resolution_dates' in test_name: - assert len(resolution_dates) == 0 + # empty list should not wipe history + assert parse(existing_resolution_date).date() in resolution_dates