From 91bb978e15b4c7b41510bfabd2270e8e39c2b6ee Mon Sep 17 00:00:00 2001 From: Doug Lovett Date: Wed, 24 Apr 2024 11:50:58 -0700 Subject: [PATCH] Initial implementation register Securities Act financing statement. Signed-off-by: Doug Lovett --- .../financingStatementV2.html | 3 + .../registration/securitiesActNotice.html | 133 ++++++++++++++ ppr-api/requirements.txt | 2 +- ppr-api/requirements/bcregistry-libraries.txt | 2 +- .../patch/15187-ppr-securities-act.sql | 59 +++++++ ppr-api/src/ppr_api/models/__init__.py | 4 + ppr-api/src/ppr_api/models/account_bcol_id.py | 16 +- ppr-api/src/ppr_api/models/client_code.py | 10 +- .../src/ppr_api/models/financing_statement.py | 31 ++++ ppr-api/src/ppr_api/models/registration.py | 7 +- .../src/ppr_api/models/registration_utils.py | 15 ++ .../ppr_api/models/securities_act_notice.py | 107 ++++++++++++ .../ppr_api/models/securities_act_order.py | 104 +++++++++++ ppr-api/src/ppr_api/models/type_tables.py | 30 ++++ ppr-api/src/ppr_api/models/utils.py | 2 + ppr-api/src/ppr_api/reports/v2/report.py | 11 +- ppr-api/src/ppr_api/resources/utils.py | 4 +- .../resources/v1/financing_statements.py | 2 +- .../src/ppr_api/resources/v1/party_codes.py | 10 +- .../utils/validators/financing_validator.py | 49 +++++- ppr-api/src/ppr_api/version.py | 2 +- ppr-api/test_data/postgres_create_first.sql | 19 +- .../postgres_data_files/test0022.sql | 43 +++++ ppr-api/test_data/postgres_test_reset.sql | 6 +- ppr-api/test_data/test_reset.sql | 2 + ppr-api/tests/unit/api/test_financing.py | 56 +++++- ppr-api/tests/unit/api/test_party_codes.py | 28 +-- ppr-api/tests/unit/api/test_utils.py | 2 +- .../tests/unit/models/test_account_bcol_id.py | 13 ++ ppr-api/tests/unit/models/test_client_code.py | 19 +- .../unit/models/test_financing_statement.py | 81 ++++++--- .../tests/unit/models/test_registration.py | 36 +++- .../unit/models/test_securities_act_notice.py | 99 +++++++++++ .../unit/models/test_securities_act_order.py | 114 ++++++++++++ .../unit/utils/test_financing_validator.py | 165 +++++++++++++++--- 35 files changed, 1182 insertions(+), 104 deletions(-) create mode 100644 ppr-api/report-templates/template-parts/registration/securitiesActNotice.html create mode 100644 ppr-api/src/database/patch/15187-ppr-securities-act.sql create mode 100644 ppr-api/src/ppr_api/models/securities_act_notice.py create mode 100644 ppr-api/src/ppr_api/models/securities_act_order.py create mode 100644 ppr-api/test_data/postgres_data_files/test0022.sql create mode 100644 ppr-api/tests/unit/models/test_securities_act_notice.py create mode 100644 ppr-api/tests/unit/models/test_securities_act_order.py diff --git a/ppr-api/report-templates/financingStatementV2.html b/ppr-api/report-templates/financingStatementV2.html index e4ec5d6ac..c74f2b2d1 100644 --- a/ppr-api/report-templates/financingStatementV2.html +++ b/ppr-api/report-templates/financingStatementV2.html @@ -148,6 +148,9 @@ [[registration/debtors.html]] [[registration/vehicleCollateral.html]] [[registration/generalCollateral.html]] + {% if type == 'SE' %} + [[registration/securitiesActNotice.html]] + {% endif %} [[registration/registeringParty.html]] diff --git a/ppr-api/report-templates/template-parts/registration/securitiesActNotice.html b/ppr-api/report-templates/template-parts/registration/securitiesActNotice.html new file mode 100644 index 000000000..c9b51d379 --- /dev/null +++ b/ppr-api/report-templates/template-parts/registration/securitiesActNotice.html @@ -0,0 +1,133 @@ +
+ {% if securitiesActNotices is defined and change is not defined %} +
+ {% endif %} +
Securities Act Notices
+ {% if securitiesActNotices is defined and change is not defined %} + {% for notice in securitiesActNotices %} + + + + + + + + + + + + + + + {% if notice.securitiesActOrders %} +
Notice Court/Commission Order(s)
+ {% for order in notice.securitiesActOrders %} + + + + + + + + + + + + + + + + + + + + + + + + + + + {% endfor %} + {% endif %} + {% if not loop.last %} +
+ {% endif %} + {% endfor %} + {% elif change is defined and change.securitiesActNotices is defined %} + {% for notice in change.securitiesActNotices %} + + + + + + + + + + + + + + + {% if notice.securitiesActOrders %} +
Notice Court/Commission Order(s)
+ {% for order in notice.securitiesActOrders %} + + + + + + + + + + + + + + + + + + + + + + + + + + + {% endfor %} + {% endif %} + {% if not loop.last %} +
+ {% endif %} + {% endfor %} + {% endif %} +
diff --git a/ppr-api/requirements.txt b/ppr-api/requirements.txt index 692b36dd7..ca4e069f5 100644 --- a/ppr-api/requirements.txt +++ b/ppr-api/requirements.txt @@ -62,4 +62,4 @@ strict-rfc3339==0.7 typing-inspect==0.7.1 typing_extensions==4.9.0 urllib3==1.26.15 -git+https://github.com/bcgov/registry-schemas.git@1.7.3#egg=registry_schemas +git+https://github.com/bcgov/registry-schemas.git@1.8.10#egg=registry_schemas diff --git a/ppr-api/requirements/bcregistry-libraries.txt b/ppr-api/requirements/bcregistry-libraries.txt index c1e81679e..c501314e4 100644 --- a/ppr-api/requirements/bcregistry-libraries.txt +++ b/ppr-api/requirements/bcregistry-libraries.txt @@ -1 +1 @@ -git+https://github.com/bcgov/registry-schemas.git@1.7.3#egg=registry_schemas +git+https://github.com/bcgov/registry-schemas.git@1.8.10#egg=registry_schemas diff --git a/ppr-api/src/database/patch/15187-ppr-securities-act.sql b/ppr-api/src/database/patch/15187-ppr-securities-act.sql new file mode 100644 index 000000000..9066ace5b --- /dev/null +++ b/ppr-api/src/database/patch/15187-ppr-securities-act.sql @@ -0,0 +1,59 @@ +-- 15178 begin release 1.2.5 +INSERT INTO registration_types(registration_type, registration_type_cl, registration_desc, registration_act) VALUES +('SE', 'MISCLIEN', 'SECURITIES ACT NOTICE', 'SECURITIES ACT'); + +ALTER TABLE account_bcol_ids + ADD COLUMN securities_act_ind VARCHAR(1) NULL CHECK (securities_act_ind IN ('Y', 'N')); + +CREATE TYPE public.securities_act_type AS ENUM ('LIEN', 'PRESERVATION', 'PROCEEDINGS'); +CREATE TABLE public.securities_act_types ( + securities_act_type public.securities_act_type PRIMARY KEY, + securities_act_type_desc VARCHAR (100) NOT NULL +); +INSERT INTO securities_act_types(securities_act_type, securities_act_type_desc) VALUES +('LIEN', 'SECURITIES ACT NOTICE OF LIEN'), +('PRESERVATION', 'SECURITIES ACT NOTICE OF PRESERVATION ORDER'), +('PROCEEDINGS', 'SECURITIES ACT NOTICE OF PROCEEDINGS') +; + +CREATE SEQUENCE securities_act_notice_id_seq INCREMENT 1 START 1; +CREATE TABLE public.securities_act_notices ( + id INTEGER PRIMARY KEY, + registration_id INTEGER NOT NULL, + registration_id_end INTEGER NULL, + securities_act_type public.securities_act_type NOT NULL, + effective_ts TIMESTAMP NOT NULL, + detail_description VARCHAR (4000) NULL, + FOREIGN KEY (securities_act_type) + REFERENCES securities_act_types (securities_act_type), + FOREIGN KEY (registration_id) + REFERENCES registrations (id), + FOREIGN KEY (registration_id_end) + REFERENCES registrations (id) +); +CREATE INDEX ix_sec_notices_registration_id ON public.securities_act_notices USING btree (registration_id); +CREATE INDEX ix_sec_notices_change_registration_id ON public.securities_act_notices USING btree (registration_id_end); + +CREATE SEQUENCE securities_act_order_id_seq INCREMENT 1 START 1; +CREATE TABLE public.securities_act_orders ( + id INTEGER PRIMARY KEY, + registration_id INTEGER NOT NULL, + securities_act_notice_id INTEGER NOT NULL, + court_order_ind VARCHAR (1) NOT NULL CHECK (court_order_ind IN ('Y', 'N')), + registration_id_end INTEGER NULL, + order_date TIMESTAMP NOT NULL, + court_name VARCHAR (256) NULL, + court_registry VARCHAR (64) NULL, + file_number VARCHAR (20) NULL, + effect_of_order VARCHAR (512) NULL, + FOREIGN KEY (securities_act_notice_id) + REFERENCES securities_act_notices (id), + FOREIGN KEY (registration_id) + REFERENCES registrations (id), + FOREIGN KEY (registration_id_end) + REFERENCES registrations (id) +); +CREATE INDEX ix_sec_orders_sec_id ON public.securities_act_orders USING btree (securities_act_notice_id); +CREATE INDEX ix_sec_orders_registration_id ON public.securities_act_orders USING btree (registration_id); +CREATE INDEX ix_sec_orders_change_registration_id ON public.securities_act_orders USING btree (registration_id_end); +-- 15178 end release 1.2.5 diff --git a/ppr-api/src/ppr_api/models/__init__.py b/ppr-api/src/ppr_api/models/__init__.py index 068c586be..60b0458c6 100644 --- a/ppr-api/src/ppr_api/models/__init__.py +++ b/ppr-api/src/ppr_api/models/__init__.py @@ -29,6 +29,8 @@ from .party import Party from .previous_financing_statement import PreviousFinancingStatement from .registration import Registration +from .securities_act_notice import SecuritiesActNotice +from .securities_act_order import SecuritiesActOrder from .search_request import SearchRequest from .search_result import SearchResult from .trust_indenture import TrustIndenture @@ -39,6 +41,7 @@ ProvinceType, RegistrationType, RegistrationTypeClass, + SecuritiesActType, SearchType, SerialType, StateType, @@ -54,5 +57,6 @@ 'EventTrackingType', 'FinancingStatement', 'GeneralCollateral', 'GeneralCollateralLegacy', 'MailReport', 'Party', 'PartyType', 'PreviousFinancingStatement', 'ProvinceType', 'Registration', 'RegistrationType', 'RegistrationTypeClass', 'SearchRequest', 'SearchResult', 'SearchType', 'StateType', 'SerialType', + 'SecuritiesActNotice', 'SecuritiesActOrder', 'SecuritiesActType', 'TrustIndenture', 'User', 'UserExtraRegistration', 'UserProfile', 'VehicleCollateral', 'VerificationReport') diff --git a/ppr-api/src/ppr_api/models/account_bcol_id.py b/ppr-api/src/ppr_api/models/account_bcol_id.py index 77ae7417f..ed1545c10 100644 --- a/ppr-api/src/ppr_api/models/account_bcol_id.py +++ b/ppr-api/src/ppr_api/models/account_bcol_id.py @@ -25,13 +25,15 @@ class AccountBcolId(db.Model): __versioned__ = {} __tablename__ = 'account_bcol_ids' - CROWN_CHARGE_YES = 'Y' + INDICATOR_YES: str = 'Y' + CROWN_CHARGE_YES = INDICATOR_YES id = db.mapped_column('id', db.Integer, db.Sequence('account_bcol_id_seq'), primary_key=True) account_id = db.mapped_column('account_id', db.String(20), nullable=False, index=True) bconline_account = db.mapped_column('bconline_account', db.Integer, nullable=False) # Only set when account is a crown charge account. crown_charge_ind = db.mapped_column('crown_charge_ind', db.String(1), nullable=True) + securities_act_ind = db.mapped_column('securities_act_ind', db.String(1), nullable=True) def save(self): """Store the User into the local cache.""" @@ -79,7 +81,17 @@ def crown_charge_account(account_id: str) -> bool: """Check if an account is configured for crown charge request types.""" account_mappings = db.session.query(AccountBcolId).\ filter(AccountBcolId.account_id == account_id, - AccountBcolId.crown_charge_ind == AccountBcolId.CROWN_CHARGE_YES).all() + AccountBcolId.crown_charge_ind == AccountBcolId.INDICATOR_YES).all() + if account_mappings: + return True + return False + + @staticmethod + def securities_act_account(account_id: str) -> bool: + """Check if an account is configured to submit securities act related registrations.""" + account_mappings = db.session.query(AccountBcolId).\ + filter(AccountBcolId.account_id == account_id, + AccountBcolId.securities_act_ind == AccountBcolId.INDICATOR_YES).all() if account_mappings: return True return False diff --git a/ppr-api/src/ppr_api/models/client_code.py b/ppr-api/src/ppr_api/models/client_code.py index 54ab77cc3..466131b75 100644 --- a/ppr-api/src/ppr_api/models/client_code.py +++ b/ppr-api/src/ppr_api/models/client_code.py @@ -153,7 +153,7 @@ def find_by_head_office_start(cls, head_office_id: str): return party_codes @classmethod - def find_by_account_id(cls, account_id: str, crown_charge: bool = True): + def find_by_account_id(cls, account_id: str, crown_charge: bool = True, securities_act: bool = False): """Return a list of client parties searching by account ID using the account id - bcol id mapping table.""" party_codes = [] if not account_id: @@ -164,9 +164,13 @@ def find_by_account_id(cls, account_id: str, crown_charge: bool = True): if bcol_accounts: for account in bcol_accounts: if crown_charge and account.crown_charge_ind and \ - account.crown_charge_ind == AccountBcolId.CROWN_CHARGE_YES: + account.crown_charge_ind == AccountBcolId.INDICATOR_YES: ids.append(account.bconline_account) - elif not crown_charge and not account.crown_charge_ind: + elif securities_act and account.securities_act_ind and \ + account.securities_act_ind == AccountBcolId.INDICATOR_YES: + ids.append(account.bconline_account) + elif not crown_charge and not account.crown_charge_ind and \ + not securities_act and not account.securities_act_ind: ids.append(account.bconline_account) if not ids: return party_codes diff --git a/ppr-api/src/ppr_api/models/financing_statement.py b/ppr-api/src/ppr_api/models/financing_statement.py index 419191c07..3059f7a4d 100644 --- a/ppr-api/src/ppr_api/models/financing_statement.py +++ b/ppr-api/src/ppr_api/models/financing_statement.py @@ -34,6 +34,7 @@ from .general_collateral_legacy import GeneralCollateralLegacy # noqa: F401 pylint: disable=unused-import; see above from .user_extra_registration import UserExtraRegistration # noqa: F401 pylint: disable=unused-import; needed by the SQLAlchemy relationship from .vehicle_collateral import VehicleCollateral # noqa: F401 pylint: disable=unused-import; needed by the SQLAlchemy relationship +from .securities_act_notice import SecuritiesActNotice # noqa: F401 pylint: disable=unused-import; needed by the SQLAlchemy relationship class FinancingStatement(db.Model): # pylint: disable=too-many-instance-attributes @@ -145,6 +146,8 @@ def json(self) -> dict: statement['lienAmount'] = reg.lien_value if reg.surrender_date: statement['surrenderDate'] = model_utils.format_ts(reg.surrender_date) + if reg.registration_type == model_utils.REG_TYPE_SECURITIES_NOTICE: + statement['securitiesActNotices'] = self.securities_act_notices_json(registration_id) if self.trust_indenture: for trust in self.trust_indenture: @@ -351,6 +354,34 @@ def vehicle_collateral_json(self, registration_id): return collateral_list + def securities_act_notices_json(self, registration_id): + """Build securities act notices JSON: current_view_json determines if current or original data is included.""" + notices_list = [] + if not self.registration: + return notices_list + for reg in self.registration: + if not self.current_view_json and reg.id == registration_id and reg.securities_act_notices: + for notice in reg.securities_act_notices: + notices_list.append(notice.json) + return notices_list + if self.current_view_json and reg.securities_act_notices: + for notice in reg.securities_act_notices: + notice_json = None + if not notice.registration_id_end and \ + (self.verification_reg_id < 1 or self.verification_reg_id >= notice.registration_id): + notice_json = notice.json + if self.mark_update_json and notice.registration_id != registration_id: + notice_json['added'] = True + elif notice.registration_id_end and self.verification_reg_id > 0 and \ + self.verification_reg_id >= notice.registration_id and \ + self.verification_reg_id < notice.registration_id_end: + notice_json = notice.json + if self.mark_update_json and notice.registration_id != registration_id: + notice_json['added'] = True + if notice_json: + notices_list.append(notice.json) + return notices_list + def validate_debtor_name(self, debtor_name_json, staff: bool = False): """Verify supplied debtor name when registering non-financing statements. Bypass the check for staff. Debtor name match rules: diff --git a/ppr-api/src/ppr_api/models/registration.py b/ppr-api/src/ppr_api/models/registration.py index 1dcae433c..2b42a8acc 100644 --- a/ppr-api/src/ppr_api/models/registration.py +++ b/ppr-api/src/ppr_api/models/registration.py @@ -79,6 +79,7 @@ class MiscellaneousTypes(BaseEnum): MH_NOTICE = 'MN' POC_NOTICE = 'PN' WAGES_UNPAID = 'WL' + SECURITIES_NOTICE = 'SE' class PPSATypes(BaseEnum): @@ -152,6 +153,8 @@ class RegistrationTypes(BaseEnum): trust_indenture = db.relationship('TrustIndenture', back_populates='registration', uselist=False) court_order = db.relationship('CourtOrder', back_populates='registration', uselist=False) verification_report = db.relationship('VerificationReport', back_populates='registration', uselist=False) + securities_act_notices = db.relationship('SecuritiesActNotice', order_by='asc(SecuritiesActNotice.id)', + back_populates='registration') document_number: str = None @@ -539,7 +542,6 @@ def find_all_by_account_id_api_filter(cls, params: AccountRegistrationParams, ne results = db.session.execute(text(query), query_params) rows = results.fetchall() results_json = registration_utils.update_account_reg_results(params, rows, results_json, True) - return results_json @classmethod @@ -791,7 +793,8 @@ def create_financing_from_json(json_data, account_id: str = None, user_id: str = else: draft.draft = json_data registration.draft = draft - + if reg_type == model_utils.REG_TYPE_SECURITIES_NOTICE and json_data.get('securitiesActNotices'): + registration = registration_utils.create_securities_act_notices(registration, json_data) return registration @staticmethod diff --git a/ppr-api/src/ppr_api/models/registration_utils.py b/ppr-api/src/ppr_api/models/registration_utils.py index 318f74d2f..0186b5898 100644 --- a/ppr-api/src/ppr_api/models/registration_utils.py +++ b/ppr-api/src/ppr_api/models/registration_utils.py @@ -18,6 +18,8 @@ from ppr_api.models import utils as model_utils from ppr_api.services.authz import is_all_staff_account +from .securities_act_notice import SecuritiesActNotice + PARAM_TO_ORDER_BY = { 'registrationNumber': 'registration_number', @@ -417,3 +419,16 @@ def api_account_reg_filter(params: AccountRegistrationParams) -> bool: (params.start_date_time and params.end_date_time)): return True return False + + +def create_securities_act_notices(registration, json_data: dict): + """Conditionally create securities act notices based on registration type.""" + if registration.registration_type == model_utils.REG_TYPE_SECURITIES_NOTICE and \ + json_data.get('securitiesActNotices'): + notices = [] + for notice_json in json_data.get('securitiesActNotices'): + notices.append(SecuritiesActNotice.create_from_json(notice_json, + registration.registration_ts, + registration.id)) + registration.securities_act_notices = notices + return registration diff --git a/ppr-api/src/ppr_api/models/securities_act_notice.py b/ppr-api/src/ppr_api/models/securities_act_notice.py new file mode 100644 index 000000000..fb2438ddb --- /dev/null +++ b/ppr-api/src/ppr_api/models/securities_act_notice.py @@ -0,0 +1,107 @@ +# Copyright © 2019 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. +"""This module holds specific securities act registration information.""" +from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM + +from .utils import format_ts, ts_from_iso_format +from .db import db +from .securities_act_order import SecuritiesActOrder +from .type_tables import SecuritiesActTypes + + +class SecuritiesActNotice(db.Model): + """This class manages the securities act registration extra information.""" + + __tablename__ = 'securities_act_notices' + + id = db.mapped_column('id', db.Integer, db.Sequence('securities_act_notice_id_seq'), primary_key=True) + effective_ts = db.mapped_column('effective_ts', db.DateTime, nullable=False) + detail_description = db.mapped_column('detail_description', db.String(4000), nullable=True) + registration_id_end = db.mapped_column('registration_id_end', db.Integer, nullable=True, index=True) + + # parent keys + registration_id = db.mapped_column('registration_id', db.Integer, db.ForeignKey('registrations.id'), + nullable=False, + index=True) + securities_act_type = db.mapped_column('securities_act_type', + PG_ENUM(SecuritiesActTypes, name='securitiesacttype'), + db.ForeignKey('securities_act_types.securities_act_type'), + nullable=False) + + # Relationships - Registration + registration = db.relationship('Registration', foreign_keys=[registration_id], + back_populates='securities_act_notices', cascade='all, delete', uselist=False) + sec_act_type = db.relationship('SecuritiesActType', foreign_keys=[securities_act_type], + back_populates='securities_act_notice', cascade='all, delete', uselist=False) + securities_act_orders = db.relationship('SecuritiesActOrder', order_by='asc(SecuritiesActOrder.id)', + back_populates='securities_act_notice') + + @property + def json(self) -> dict: + """Return the securities act as a json object.""" + securities_act = { + 'securitiesActNoticeType': self.securities_act_type, + 'effectiveDateTime': format_ts(self.effective_ts) + } + if self.detail_description: + securities_act['description'] = self.detail_description + if self.sec_act_type: + securities_act['registrationDescription'] = self.sec_act_type.securities_act_type_desc + if self.securities_act_orders: + orders = [] + for order in self.securities_act_orders: + orders.append(order.json) + securities_act['securitiesActOrders'] = orders + return securities_act + + @classmethod + def find_by_id(cls, sec_id: int): + """Return an securities act object by primary key ID.""" + securities_act = None + if sec_id: + securities_act = db.session.query(SecuritiesActNotice).filter(SecuritiesActNotice.id == sec_id) \ + .one_or_none() + + return securities_act + + @classmethod + def find_by_registration_id(cls, reg_id: int): + """Return a list of securities act objects by registration id.""" + securities_acts = None + if reg_id: + securities_acts = db.session.query(SecuritiesActNotice) \ + .filter(SecuritiesActNotice.registration_id == reg_id) \ + .order_by(SecuritiesActNotice.id).all() + return securities_acts + + @staticmethod + def create_from_json(json_data, registration_ts, registration_id: int): + """Create a securities act object from a registration json schema object: map json to db.""" + securities_act = SecuritiesActNotice(securities_act_type=json_data.get('securitiesActNoticeType'), + registration_id=registration_id) + if json_data.get('effectiveDateTime'): + securities_act.effective_ts = ts_from_iso_format(json_data.get('effectiveDateTime')) + else: + securities_act.effective_ts = registration_ts + if json_data.get('description'): + securities_act.detail_description = str(json_data.get('description')).strip() + if json_data.get('securitiesActOrders'): + orders = [] + for order_json in json_data.get('securitiesActOrders'): + securities_act_order: SecuritiesActOrder = SecuritiesActOrder.create_from_json(order_json, + registration_id, + securities_act.id) + orders.append(securities_act_order) + securities_act.securities_act_orders = orders + return securities_act diff --git a/ppr-api/src/ppr_api/models/securities_act_order.py b/ppr-api/src/ppr_api/models/securities_act_order.py new file mode 100644 index 000000000..9efac8aaf --- /dev/null +++ b/ppr-api/src/ppr_api/models/securities_act_order.py @@ -0,0 +1,104 @@ +# Copyright © 2019 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. +"""This module holds data for Securities Act registration and amendment court/commission order information.""" +from .utils import format_ts, ts_from_date_iso_format +from .db import db + + +class SecuritiesActOrder(db.Model): + """This class manages the Securities Act registration and amendment court/commission order information.""" + + __tablename__ = 'securities_act_orders' + + id = db.mapped_column('id', db.Integer, db.Sequence('securities_act_order_id_seq'), primary_key=True) + court_order_ind = db.mapped_column('court_order_ind', db.String(1), nullable=False) + order_date = db.mapped_column('order_date', db.DateTime, nullable=True) + court_name = db.mapped_column('court_name', db.String(256), nullable=True) + court_registry = db.mapped_column('court_registry', db.String(64), nullable=True) + file_number = db.mapped_column('file_number', db.String(20), nullable=True) + effect_of_order = db.mapped_column('effect_of_order', db.String(512), nullable=True) + registration_id_end = db.mapped_column('registration_id_end', db.Integer, nullable=True, index=True) + + # parent keys + registration_id = db.mapped_column('registration_id', db.Integer, + db.ForeignKey('registrations.id'), + nullable=False, + index=True) + + securities_act_notice_id = db.mapped_column('securities_act_notice_id', db.Integer, + db.ForeignKey('securities_act_notices.id'), + nullable=False, + index=True) + + # Relationships - Registration + securities_act_notice = db.relationship('SecuritiesActNotice', foreign_keys=[securities_act_notice_id], + back_populates='securities_act_orders', + cascade='all, delete', uselist=False) + + @property + def json(self) -> dict: + """Return the court_order as a json object.""" + order = { + 'courtOrder': self.court_order_ind == 'Y' + } + if self.court_name: + order['courtName'] = self.court_name + if self.court_registry: + order['courtRegistry'] = self.court_registry + if self.file_number: + order['fileNumber'] = self.file_number + if self.order_date: + order['orderDate'] = format_ts(self.order_date) + if self.effect_of_order: + order['effectOfOrder'] = self.effect_of_order + return order + + @classmethod + def find_by_id(cls, order_id: int = None): + """Return an Securities Act Order object by order ID.""" + order = None + if order_id: + order = db.session.query(SecuritiesActOrder).filter(SecuritiesActOrder.id == order_id).one_or_none() + return order + + @classmethod + def find_by_notice_id(cls, notice_id: int = None): + """Return a list of Securities Act Order objects by notice ID.""" + orders = None + if notice_id: + orders = db.session.query(SecuritiesActOrder) \ + .filter(SecuritiesActOrder.securities_act_notice_id == notice_id) \ + .order_by(SecuritiesActOrder.id).all() + return orders + + @staticmethod + def create_from_json(json_data, registration_id: int, notice_id: int): + """Create a Securities Act court/commission order object from a json schema object: map json to db.""" + order: SecuritiesActOrder = SecuritiesActOrder(registration_id=registration_id, + securities_act_notice_id=notice_id, + court_order_ind='Y') + if not json_data.get('courtOrder'): + order.court_order_ind = 'N' + if json_data.get('courtName'): + order.court_name = json_data['courtName'] + if json_data.get('courtRegistry'): + order.court_registry = json_data['courtRegistry'] + if json_data.get('fileNumber'): + order.file_number = json_data['fileNumber'] + if json_data.get('orderDate'): + order.order_date = ts_from_date_iso_format(json_data['orderDate']) + if json_data.get('effectOfOrder'): + order.effect_of_order = json_data['effectOfOrder'] + + return order diff --git a/ppr-api/src/ppr_api/models/type_tables.py b/ppr-api/src/ppr_api/models/type_tables.py index 0d2338955..53df41bfe 100644 --- a/ppr-api/src/ppr_api/models/type_tables.py +++ b/ppr-api/src/ppr_api/models/type_tables.py @@ -15,9 +15,20 @@ from __future__ import annotations +from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM +from ppr_api.utils.base import BaseEnum + from .db import db +class SecuritiesActTypes(BaseEnum): + """Render an Enum of the Securities Act types.""" + + LIEN = 'LIEN' + PROCEEDINGS = 'PROCEEDINGS' + PRESERVATION = 'PRESERVATION' + + class CountryType(db.Model): # pylint: disable=too-few-public-methods """This class defines the model for the country_type table.""" @@ -147,3 +158,22 @@ class EventTrackingType(db.Model): # pylint: disable=too-few-public-methods # Relationships - EventTracking event_tracking = db.relationship('EventTracking', back_populates='tracking_type') + + +class SecuritiesActType(db.Model): # pylint: disable=too-few-public-methods + """This class defines the model for the securities_act_types table.""" + + __tablename__ = 'securities_act_types' + + securities_act_type = db.mapped_column('securities_act_type', + PG_ENUM(SecuritiesActTypes, name='securitiesacttype'), + primary_key=True) + securities_act_type_desc = db.mapped_column('securities_act_type_desc', db.String(100), nullable=False) + + # Relationships - + securities_act_notice = db.relationship('SecuritiesActNotice', back_populates='sec_act_type') + + @classmethod + def find_all(cls): + """Return all the type records.""" + return db.session.query(SecuritiesActType).all() diff --git a/ppr-api/src/ppr_api/models/utils.py b/ppr-api/src/ppr_api/models/utils.py index 15e3884ad..ae5173896 100644 --- a/ppr-api/src/ppr_api/models/utils.py +++ b/ppr-api/src/ppr_api/models/utils.py @@ -61,6 +61,7 @@ REG_TYPE_TAX_MH = 'MH' REG_TYPE_OTHER = 'OT' REG_TYPE_SECURITY_AGREEMENT = 'SA' +REG_TYPE_SECURITIES_NOTICE = 'SE' # New amendment change types REG_TYPE_AMEND_ADDITION_COLLATERAL = 'AA' REG_TYPE_AMEND_DEBTOR_RELEASE = 'AR' @@ -173,6 +174,7 @@ 'HN': 'MISCLIEN', 'ML': 'MISCLIEN', 'MN': 'MISCLIEN', + 'SE': 'MISCLIEN', 'PN': 'MISCLIEN', 'WL': 'MISCLIEN', 'FA': 'PPSALIEN', diff --git a/ppr-api/src/ppr_api/reports/v2/report.py b/ppr-api/src/ppr_api/reports/v2/report.py index 6e0061965..55485aa43 100755 --- a/ppr-api/src/ppr_api/reports/v2/report.py +++ b/ppr-api/src/ppr_api/reports/v2/report.py @@ -334,6 +334,7 @@ def _substitute_template_parts(template_code): 'registration/changeStatement', 'registration/dischargeStatement', 'registration/renewalStatement', + 'registration/securitiesActNotice', 'v2/search-result/selected', 'search-result/financingStatement', 'search-result/amendmentStatement', @@ -684,7 +685,7 @@ def _set_modified_parties(self, statement): Report._set_modified_party(add_debtor, statement['deleteDebtors']) @staticmethod - def _set_financing_date_time(statement): + def _set_financing_date_time(statement): # pylint: disable=too-many-branches """Replace financing statement API ISO UTC strings with local report format strings.""" statement['createDateTime'] = Report._to_report_datetime(statement['createDateTime']) if 'expiryDate' in statement and len(statement['expiryDate']) > 10: @@ -709,6 +710,14 @@ def _set_financing_date_time(statement): lien_amount = str(statement['lienAmount']) if lien_amount.isnumeric(): statement['lienAmount'] = '$' + '{:0,.2f}'.format(float(lien_amount)) + if statement['type'] == 'SE' and statement.get('securitiesActNotices'): + for notice in statement.get('securitiesActNotices'): + if notice.get('effectiveDateTime'): + notice['effectiveDateTime'] = Report._to_report_datetime(notice['effectiveDateTime'], False) + if notice.get('securitiesActOrders'): + for order in notice.get('securitiesActOrders'): + if order.get('orderDate'): + order['orderDate'] = Report._to_report_datetime(order['orderDate'], False) @staticmethod def _set_change_date_time(statement): # pylint: disable=too-many-branches diff --git a/ppr-api/src/ppr_api/resources/utils.py b/ppr-api/src/ppr_api/resources/utils.py index 8203d5c2b..db52d5464 100644 --- a/ppr-api/src/ppr_api/resources/utils.py +++ b/ppr-api/src/ppr_api/resources/utils.py @@ -259,10 +259,10 @@ def base_debtor_invalid_response(): return jsonify({'message': message}), HTTPStatus.BAD_REQUEST -def validate_financing(json_data): +def validate_financing(json_data: dict, account_id: str) -> str: """Perform non-schema extra validation on a financing statement.""" error_msg = party_validator.validate_financing_parties(json_data) - error_msg += financing_validator.validate(json_data) + error_msg += financing_validator.validate(json_data, account_id) return error_msg diff --git a/ppr-api/src/ppr_api/resources/v1/financing_statements.py b/ppr-api/src/ppr_api/resources/v1/financing_statements.py index 882d2d843..b020c28e0 100644 --- a/ppr-api/src/ppr_api/resources/v1/financing_statements.py +++ b/ppr-api/src/ppr_api/resources/v1/financing_statements.py @@ -82,7 +82,7 @@ def post_financing_statements(): request_json = request.get_json(silent=True) # Validate request data against the schema. valid_format, errors = schema_utils.validate(request_json, 'financingStatement', 'ppr') - extra_validation_msg = resource_utils.validate_financing(request_json) + extra_validation_msg = resource_utils.validate_financing(request_json, account_id) if not valid_format or extra_validation_msg != '': return resource_utils.validation_error_response(errors, fs_utils.VAL_ERROR, extra_validation_msg) # Set up the financing statement registration, pay, and save the data. diff --git a/ppr-api/src/ppr_api/resources/v1/party_codes.py b/ppr-api/src/ppr_api/resources/v1/party_codes.py index 942162e0b..55aeff8e2 100644 --- a/ppr-api/src/ppr_api/resources/v1/party_codes.py +++ b/ppr-api/src/ppr_api/resources/v1/party_codes.py @@ -29,6 +29,7 @@ bp = Blueprint('PARTY_CODES1', # pylint: disable=invalid-name __name__, url_prefix='/api/v1/party-codes') FUZZY_NAME_SEARCH_PARAM = 'fuzzyNameSearch' +SECURITIES_ACT_PARAM = 'securitiesActCodes' @bp.route('/', methods=['GET', 'OPTIONS']) @@ -116,7 +117,14 @@ def get_account_codes(): # Try to fetch client parties: no results is an empty list. current_app.logger.debug(f'Getting {account_id} party codes.') - parties = ClientCode.find_by_account_id(account_id, True) + # Default filter is crown charge account party codes. + is_crown_charge: bool = True + is_securities_act: bool = False + securities_act_param = request.args.get(SECURITIES_ACT_PARAM) + if securities_act_param: + is_crown_charge: bool = False + is_securities_act: bool = True + parties = ClientCode.find_by_account_id(account_id, is_crown_charge, is_securities_act) return jsonify(parties), HTTPStatus.OK except DatabaseException as db_exception: diff --git a/ppr-api/src/ppr_api/utils/validators/financing_validator.py b/ppr-api/src/ppr_api/utils/validators/financing_validator.py index d6f69115b..dc6d193f1 100644 --- a/ppr-api/src/ppr_api/utils/validators/financing_validator.py +++ b/ppr-api/src/ppr_api/utils/validators/financing_validator.py @@ -18,7 +18,7 @@ """ # pylint: disable=superfluous-parens -from ppr_api.models import utils as model_utils, VehicleCollateral +from ppr_api.models import ClientCode, utils as model_utils, VehicleCollateral from ppr_api.models.registration import MiscellaneousTypes, PPSATypes @@ -46,6 +46,13 @@ VC_MH_ONLY = 'Only Vehicle Collateral type MH is allowed with this registration type. ' VC_MH_NOT_ALLOWED = 'Vehicle Collateral type MH is not allowed with this registration type. ' VC_AP_NOT_ALLOWED = 'Vehicle Collateral type AP is not allowed. ' +SE_NOTICES_MISSING = 'The Securities Act Notice SE type requires securitiesActNotices. ' +SE_SECURED_COUNT_INVALID = 'The Securities Act Notice SE type can only have 1 Secured Party. ' +SE_ACCESS_INVALID = 'Not authorized: the Securities Act Notice SE type is restricted by account ID. ' +SE_RP_MISSING_CODE = 'The Securities Act Notice SE type Registering Party must use an account party code. ' +SE_SP_MISSING_CODE = 'The Securities Act Notice SE type Secured Party must use an account client party code. ' +SE_RP_INVALID_CODE = 'The Securities Act Notice SE type Registering Party client party code is invalid. ' +SE_SP_INVALID_CODE = 'The Securities Act Notice SE type Secured Party client party code is invalid. ' GC_NOT_ALLOWED_LIST = [MiscellaneousTypes.MH_NOTICE.value, PPSATypes.MARRIAGE_SEPARATION.value, @@ -56,7 +63,8 @@ PPSATypes.FORESTRY_LIEN.value, PPSATypes.FORESTRY_SUB_CHARGE.value, MiscellaneousTypes.HC_NOTICE.value, - MiscellaneousTypes.WAGES_UNPAID.value] + MiscellaneousTypes.WAGES_UNPAID.value, + MiscellaneousTypes.SECURITIES_NOTICE] VC_REQUIRED_LIST = [MiscellaneousTypes.MH_NOTICE.value, PPSATypes.MARRIAGE_SEPARATION.value, PPSATypes.REPAIRER_LIEN.value, @@ -71,7 +79,7 @@ PPSATypes.LAND_TAX.value] -def validate(json_data): +def validate(json_data: dict, account_id: str) -> str: """Apply validation rules for all financing statement registration types.""" error_msg = '' try: @@ -95,11 +103,46 @@ def validate(json_data): error_msg += validate_trust_indenture(json_data, reg_type) error_msg += validate_rl(json_data, reg_type) error_msg += validate_other_description(json_data, reg_type) + if reg_type == model_utils.REG_TYPE_SECURITIES_NOTICE: + error_msg += validate_securities_act(json_data, account_id) return error_msg except ValueError: return error_msg +def validate_securities_act(json_data: dict, account_id: str) -> str: # pylint: disable=too-many-branches; 1 more + """Validate rules specific to the securities act registration type.""" + error_msg = '' + if not json_data.get('securitiesActNotices'): + error_msg += SE_NOTICES_MISSING + if json_data.get('securedParties') and len(json_data['securedParties']) > 1: + error_msg += SE_SECURED_COUNT_INVALID + parties = ClientCode.find_by_account_id(account_id, False, True) + if not parties: + error_msg += SE_ACCESS_INVALID + else: + rp_code = None + sp_code = None + if json_data.get('registeringParty') and not json_data['registeringParty'].get('code'): + error_msg += SE_RP_MISSING_CODE + else: + rp_code = json_data['registeringParty'].get('code') + if json_data.get('securedParties') and not json_data['securedParties'][0].get('code'): + error_msg += SE_SP_MISSING_CODE + else: + sp_code = json_data['securedParties'][0].get('code') + for client_party in parties: + if client_party.get('code') == rp_code: + rp_code = 'found' + if client_party.get('code') == sp_code: + sp_code = 'found' + if rp_code and rp_code != 'found': + error_msg += SE_RP_INVALID_CODE + if sp_code and sp_code != 'found': + error_msg += SE_SP_INVALID_CODE + return error_msg + + def validate_life(json_data, reg_type: str, reg_class: str): """Validate lifeYears and lifeInfinite by registration type.""" error_msg = '' diff --git a/ppr-api/src/ppr_api/version.py b/ppr-api/src/ppr_api/version.py index c3b30d405..c91026d94 100644 --- a/ppr-api/src/ppr_api/version.py +++ b/ppr-api/src/ppr_api/version.py @@ -22,4 +22,4 @@ Development release segment: .devN """ -__version__ = '1.2.4' # pylint: disable=invalid-name +__version__ = '1.2.5' # pylint: disable=invalid-name diff --git a/ppr-api/test_data/postgres_create_first.sql b/ppr-api/test_data/postgres_create_first.sql index 62eddf968..1f7bd6a58 100644 --- a/ppr-api/test_data/postgres_create_first.sql +++ b/ppr-api/test_data/postgres_create_first.sql @@ -35,12 +35,14 @@ INSERT INTO client_codes(HEAD_ID, ID, ADDRESS_ID, NAME, BCONLINE_ACCOUNT, CONTAC -- Account ID BCOL Account Number mapping -INSERT INTO account_bcol_ids(id, account_id, bconline_account, crown_charge_ind) - VALUES (200000000, 'PS12345', 200000000, 'Y'); -INSERT INTO account_bcol_ids(id, account_id, bconline_account, crown_charge_ind) - VALUES (200000001, 'PS12345', 200000001, 'Y'); -INSERT INTO account_bcol_ids(id, account_id, bconline_account, crown_charge_ind) - VALUES (200000002, 'PS00001', 200000002, null); +INSERT INTO account_bcol_ids(id, account_id, bconline_account, crown_charge_ind, securities_act_ind) + VALUES (200000000, 'PS12345', 200000000, 'Y', null); +INSERT INTO account_bcol_ids(id, account_id, bconline_account, crown_charge_ind, securities_act_ind) + VALUES (200000001, 'PS12345', 200000001, 'Y', null); +INSERT INTO account_bcol_ids(id, account_id, bconline_account, crown_charge_ind, securities_act_ind) + VALUES (200000002, 'PS00001', 200000002, null, null); +INSERT INTO account_bcol_ids(id, account_id, bconline_account, crown_charge_ind, securities_act_ind) + VALUES (200000003, 'PS00002', 200000003, null, 'Y'); -- Add code_historical when Bob provides examples of how name/address changes work. @@ -70,3 +72,8 @@ INSERT INTO client_codes(HEAD_ID, ID, ADDRESS_ID, NAME, BCONLINE_ACCOUNT, CONTAC CONTACT_PHONE_NUMBER, EMAIL_ADDRESS, USERS_ID, USER_ID, DATE_TS) VALUES (9999,99990004,99990004,'RBC ROYAL BANK',12345,'TEST BRANCH 4 CONTACT NAME','250','3564670', 'test-4@test-rbc.com',null,null,null); + +INSERT INTO client_codes(HEAD_ID, ID, ADDRESS_ID, NAME, BCONLINE_ACCOUNT, CONTACT_NAME,CONTACT_AREA_CD, + CONTACT_PHONE_NUMBER, EMAIL_ADDRESS, USERS_ID, USER_ID, DATE_TS) + VALUES (9998,99980001,99990004,'TEST SECURITIES ACT COMMISION',200000003,'CONTACT NAME','250','3564670', + 'test@test-sac.com',null,null,null); diff --git a/ppr-api/test_data/postgres_data_files/test0022.sql b/ppr-api/test_data/postgres_data_files/test0022.sql new file mode 100644 index 000000000..bc406129c --- /dev/null +++ b/ppr-api/test_data/postgres_data_files/test0022.sql @@ -0,0 +1,43 @@ +-- New securities act registration, amendment, discharge. +-- Secured party should match account ID. +INSERT INTO drafts(id, document_number, account_id, create_ts, registration_type_cl, registration_type, + registration_number, update_ts, draft) + VALUES(200000041, 'D-T-0022', 'PS00002', now() at time zone 'utc', 'MISCLIEN', 'SE', 'TEST0022', null, '{}'); +INSERT INTO financing_statements(id, state_type, expire_date, life, discharged, renewed) + VALUES(200000017, 'ACT', null, 99, 'N' , null) +; +INSERT INTO registrations(id, financing_id, registration_number, base_reg_number, registration_type, + registration_type_cl, registration_ts, draft_id, life, lien_value, + surrender_date, account_id, client_reference_id, pay_invoice_id, pay_path) + VALUES(200000038, 200000017, 'TEST0022', null, 'SE', 'MISCLIEN', + now() at time zone 'utc', 200000041, 99, + null, null, 'PS00002', 'TEST-SE-0022', null, null) +; +INSERT INTO securities_act_notices(id, registration_id, registration_id_end, securities_act_type, effective_ts, detail_description) + VALUES (200000000, 200000038, null, 'PRESERVATION', (now() at time zone 'utc') - interval '1 days', 'UNIT TEST PRESERVATION ORDER'); +INSERT INTO securities_act_orders(id, registration_id, registration_id_end, securities_act_notice_id, court_order_ind, + order_date, court_name, court_registry, file_number, effect_of_order) + VALUES (200000000, 200000038, null, 200000000, 'Y', (now() at time zone 'utc') - interval '1 days', 'COURT NAME', + 'COURT REGISTRY', 'FILE# 00001', 'UNIT TEST'); +INSERT INTO addresses(id, street, street_additional, city, region, postal_code, country) + VALUES(200000038, 'TEST-0022', 'line 2', 'city', 'BC', 'V8R3A5', 'CA') +; +INSERT INTO parties(id, party_type, registration_id, financing_id, registration_id_end, branch_id, first_name, + middle_initial, last_name, business_name, birth_date, address_id) + VALUES(200000083, 'SP', 200000038, 200000017, null, 99980001, null, null, null, null, null, null) +; +INSERT INTO parties(id, party_type, registration_id, financing_id, registration_id_end, branch_id, first_name, + middle_initial, last_name, business_name, birth_date, address_id) + VALUES(200000084, 'RG', 200000038, 200000017, null, 99980001, null, null, null, null, null, null) +; +INSERT INTO parties(id, party_type, registration_id, financing_id, registration_id_end, branch_id, first_name, + middle_initial, last_name, business_name, birth_date, address_id, business_srch_key) + VALUES(200000085, 'DB', 200000038, 200000017, null, null, null, null, null, 'TEST 22 DEBTOR INC.', + null, 200000038, searchkey_business_name('TEST 22 DEBTOR INC.')) +; +INSERT INTO general_collateral(id, registration_id, financing_id, registration_id_end, description, status) + VALUES(200000015, 200000038, 200000017, null, 'TEST0022 GC 1', null) +; +-- Add an amendment. + +-- Discharge. diff --git a/ppr-api/test_data/postgres_test_reset.sql b/ppr-api/test_data/postgres_test_reset.sql index d9a508e85..34e725fc2 100644 --- a/ppr-api/test_data/postgres_test_reset.sql +++ b/ppr-api/test_data/postgres_test_reset.sql @@ -19,6 +19,10 @@ DELETE FROM trust_indentures WHERE financing_id >= 200000000; DELETE FROM court_orders WHERE registration_id IN (SELECT id FROM registrations where financing_id >= 200000000); +DELETE FROM securities_act_orders + WHERE registration_id IN (SELECT id FROM registrations where financing_id >= 200000000); +DELETE FROM securities_act_notices + WHERE registration_id IN (SELECT id FROM registrations where financing_id >= 200000000); DELETE FROM registrations WHERE financing_id >= 200000000; DELETE FROM previous_financing_statements @@ -34,7 +38,7 @@ DELETE FROM client_codes DELETE FROM addresses WHERE id >= 200000000; DELETE FROM client_codes - WHERE id BETWEEN 99990001 AND 99990004; + WHERE id BETWEEN 99990001 AND 99990004 or id = 99980001; DELETE FROM addresses WHERE id BETWEEN 99990001 AND 99990004; DELETE FROM user_profiles diff --git a/ppr-api/test_data/test_reset.sql b/ppr-api/test_data/test_reset.sql index 316eb5679..f27f890d7 100644 --- a/ppr-api/test_data/test_reset.sql +++ b/ppr-api/test_data/test_reset.sql @@ -1,4 +1,6 @@ -- Delete all test data created with the scripts in this directory. +DELETE FROM securities_acts + WHERE registration_id >= 200000000; UPDATE draft SET registration_id = null WHERE draft_id >= 200000000; diff --git a/ppr-api/tests/unit/api/test_financing.py b/ppr-api/tests/unit/api/test_financing.py index 76042079c..645ed2cbc 100644 --- a/ppr-api/tests/unit/api/test_financing.py +++ b/ppr-api/tests/unit/api/test_financing.py @@ -278,6 +278,32 @@ 'trustIndenture': False, 'lifeInfinite': False } +SECURITIES_ACT_NOTICES = [ + { + 'securitiesActNoticeType': 'LIEN', + 'effectiveDateTime': '2024-04-22T06:59:59+00:00', + 'description': 'DETAIL DESC', + 'securitiesActOrders': [ + { + 'courtOrder': True, + 'courtName': 'court name', + 'courtRegistry': 'registry', + 'fileNumber': 'filenumber', + 'orderDate': '2024-04-22T06:59:59+00:00', + 'effectOfOrder': 'effect' + } + ] + } +] +SE_SP = [ + { + 'code': '99980001' + } +] +SE_RP = { + 'code': '99980001' +} + # testdata pattern is ({description}, {test data}, {roles}, {status}, {has_account}) TEST_CREATE_DATA = [ @@ -288,7 +314,8 @@ ('Invalid role', FINANCING_VALID, [COLIN_ROLE], HTTPStatus.UNAUTHORIZED, True), ('BCOL helpdesk account', FINANCING_VALID, [PPR_ROLE, BCOL_HELP], HTTPStatus.UNAUTHORIZED, True), ('Valid Security Agreement', FINANCING_VALID, [PPR_ROLE], HTTPStatus.CREATED, True), - ('SBC Valid Security Agreement', FINANCING_VALID, [PPR_ROLE, GOV_ACCOUNT_ROLE], HTTPStatus.CREATED, True) + ('SBC Valid Security Agreement', FINANCING_VALID, [PPR_ROLE, GOV_ACCOUNT_ROLE], HTTPStatus.CREATED, True), + ('Valid Securities Act', FINANCING_VALID, [PPR_ROLE], HTTPStatus.CREATED, True) ] # testdata pattern is ({role}, {routingSlip}, {bcolNumber}, {datNUmber}, {status}) TEST_STAFF_CREATE_DATA = [ @@ -345,6 +372,7 @@ ('Missing account', [PPR_ROLE], HTTPStatus.BAD_REQUEST, False, 'TEST0001'), ('Invalid role', [COLIN_ROLE], HTTPStatus.UNAUTHORIZED, True, 'TEST0001'), ('Valid Request', [PPR_ROLE], HTTPStatus.OK, True, 'TEST0001'), + ('Valid Request SE', [PPR_ROLE], HTTPStatus.OK, True, 'TEST0022'), ('Valid Request reg staff', [PPR_ROLE, STAFF_ROLE], HTTPStatus.OK, True, 'TEST0001'), ('Valid Request sbc staff', [PPR_ROLE, GOV_ACCOUNT_ROLE], HTTPStatus.OK, True, 'TEST0001'), ('Valid Request bcol helpdesk', [PPR_ROLE, BCOL_HELP], HTTPStatus.OK, True, 'TEST0001'), @@ -434,22 +462,33 @@ ] -@pytest.mark.parametrize('desc,json_data,roles,status,has_account', TEST_CREATE_DATA) -def test_create(session, client, jwt, desc, json_data, roles, status, has_account): +@pytest.mark.parametrize('desc,request_data,roles,status,has_account', TEST_CREATE_DATA) +def test_create(session, client, jwt, desc, request_data, roles, status, has_account): """Assert that a post financing statement works as expected.""" current_app.config.update(PAYMENT_SVC_URL=MOCK_PAY_URL) current_app.config.update(AUTH_SVC_URL=MOCK_URL_NO_KEY) headers = None + account_id = 'PS12345' # setup + json_data = copy.deepcopy(request_data) + if desc == 'Valid Securities Act': + json_data['type'] = 'SE' + json_data['lifeInfinite'] = True + del json_data['lifeYears'] + del json_data['vehicleCollateral'] + del json_data['trustIndenture'] + json_data['registeringParty'] = SE_RP + json_data['securedParties'] = SE_SP + json_data['securitiesActNotices'] = copy.deepcopy(SECURITIES_ACT_NOTICES) + account_id = 'PS00002' if has_account and BCOL_HELP in roles: headers = create_header_account(jwt, roles, 'test-user', BCOL_HELP) elif has_account and GOV_ACCOUNT_ROLE in roles: - headers = create_header_account(jwt, roles, 'test-user', '1234') + headers = create_header_account(jwt, roles, 'test-user', account_id) elif has_account: - headers = create_header_account(jwt, roles) + headers = create_header_account(jwt, roles, 'test-user', account_id) else: headers = create_header(jwt, roles) - # test response = client.post('/api/v1/financing-statements', json=json_data, @@ -457,10 +496,11 @@ def test_create(session, client, jwt, desc, json_data, roles, status, has_accoun content_type='application/json') # check + current_app.logger.info(response.json) assert response.status_code == status if response.status_code == HTTPStatus.CREATED: registration: Registration = Registration.find_by_registration_number(response.json['baseRegistrationNumber'], - 'PS12345', True) + 'PS00002', True) assert registration.verification_report @@ -620,6 +660,8 @@ def test_get_statement(session, client, jwt, desc, roles, status, has_account, r headers = create_header_account(jwt, roles, 'test-user', STAFF_ROLE) elif has_account and GOV_ACCOUNT_ROLE in roles: headers = create_header_account(jwt, roles, 'test-user', '1234') + elif has_account and reg_num == 'TEST0022': + headers = create_header_account(jwt, roles, 'test-user', 'PS00002') elif has_account: headers = create_header_account(jwt, roles) else: diff --git a/ppr-api/tests/unit/api/test_party_codes.py b/ppr-api/tests/unit/api/test_party_codes.py index 17cf4cb7b..ef0bfe8a9 100644 --- a/ppr-api/tests/unit/api/test_party_codes.py +++ b/ppr-api/tests/unit/api/test_party_codes.py @@ -41,14 +41,16 @@ ('Staff missing account ID', True, False, HTTPStatus.OK, PPR_ROLE, 'RBC Royal Bank'), ('Valid data but unauthorized', False, True, HTTPStatus.UNAUTHORIZED, COLIN_ROLE, '9999') ] -# testdata pattern is ({description}, {is staff}, {response status}, {role}, {account_id}, {has_data}) +# testdata pattern is ({description}, {is staff}, {response status}, {role}, {account_id}, {has_data}, {sec_act}) TEST_DATA_ACCOUNT = [ - ('Valid non-staff non-existent', False, HTTPStatus.OK, PPR_ROLE, 'PS1234X', False), - ('Valid non-staff CC exists', False, HTTPStatus.OK, PPR_ROLE, 'PS12345', True), - ('Valid non-staff non CC exists', False, HTTPStatus.OK, PPR_ROLE, 'PS00001', False), - ('Non-staff missing account ID', False, HTTPStatus.BAD_REQUEST, PPR_ROLE, None, False), - ('Staff missing account ID', True, HTTPStatus.BAD_REQUEST, PPR_ROLE, None, False), - ('Unauthorized role', False, HTTPStatus.UNAUTHORIZED, COLIN_ROLE, 'PS12345', False) + ('Valid non-staff non-existent', False, HTTPStatus.OK, PPR_ROLE, 'PS1234X', False, False), + ('Valid non-staff CC exists', False, HTTPStatus.OK, PPR_ROLE, 'PS12345', True, False), + ('Valid non-staff Securities Act exists', False, HTTPStatus.OK, PPR_ROLE, 'PS00002', True, True), + ('Valid non-staff Securities Act non-existent', False, HTTPStatus.OK, PPR_ROLE, 'PS12345', False, True), + ('Valid non-staff non CC exists', False, HTTPStatus.OK, PPR_ROLE, 'PS00001', False, False), + ('Non-staff missing account ID', False, HTTPStatus.BAD_REQUEST, PPR_ROLE, None, False, False), + ('Staff missing account ID', True, HTTPStatus.BAD_REQUEST, PPR_ROLE, None, False, False), + ('Unauthorized role', False, HTTPStatus.UNAUTHORIZED, COLIN_ROLE, 'PS12345', False, False) ] @@ -102,13 +104,13 @@ def test_get_head_office_codes(session, client, jwt, desc, staff, include_accoun if rv.status_code == HTTPStatus.OK: if search_value != '8999': assert rv.json - assert len(rv.json) == 4 + assert len(rv.json) >= 4 else: assert not rv.json -@pytest.mark.parametrize('desc,staff,status,role,account_id,has_data', TEST_DATA_ACCOUNT) -def test_get_account_codes(session, client, jwt, desc, staff, status, role, account_id, has_data): +@pytest.mark.parametrize('desc,staff,status,role,account_id,has_data,sec_act', TEST_DATA_ACCOUNT) +def test_get_account_codes(session, client, jwt, desc, staff, status, role, account_id, has_data, sec_act): """Assert that a get party code information by account ID returns the expected response code and data.""" # setup headers = None @@ -122,9 +124,11 @@ def test_get_account_codes(session, client, jwt, desc, staff, status, role, acco headers = create_header(jwt, [role, STAFF_ROLE]) else: headers = create_header(jwt, [role]) - + path: str = '/api/v1/party-codes/accounts' + if sec_act: + path += '?securitiesActCodes=true' # test - rv = client.get('/api/v1/party-codes/accounts', headers=headers) + rv = client.get(path, headers=headers) # check assert rv.status_code == status if rv.status_code == HTTPStatus.OK: diff --git a/ppr-api/tests/unit/api/test_utils.py b/ppr-api/tests/unit/api/test_utils.py index 884b9c051..78085cde4 100644 --- a/ppr-api/tests/unit/api/test_utils.py +++ b/ppr-api/tests/unit/api/test_utils.py @@ -189,7 +189,7 @@ def test_validate_financing(session, client, jwt, desc, valid): del json_data['authorizationReceived'] # test - error_msg = resource_utils.validate_financing(json_data) + error_msg = resource_utils.validate_financing(json_data, 'PS12345') if valid: assert error_msg == '' else: diff --git a/ppr-api/tests/unit/models/test_account_bcol_id.py b/ppr-api/tests/unit/models/test_account_bcol_id.py index 7415aa34e..7c1a21710 100644 --- a/ppr-api/tests/unit/models/test_account_bcol_id.py +++ b/ppr-api/tests/unit/models/test_account_bcol_id.py @@ -27,6 +27,12 @@ ('Exists not crown charge', False, 'PS0001'), ('Does not exist', False, 'PS1234X') ] +# testdata pattern is ({description}, {is_securities_act}, {account_id}) +TEST_DATA_SECURITIES_ACT = [ + ('Exists securities act', True, 'PS00002'), + ('Exists not securities act', False, 'PS12345'), + ('Does not exist', False, 'PS1234X') +] def test_find_by_id(session): @@ -101,3 +107,10 @@ def test_crown_charge_account(session, desc, is_crown_charge, account_id): """Assert that crown_charge_account behaves as expected.""" crown_charge_account = AccountBcolId.crown_charge_account(account_id) assert crown_charge_account == is_crown_charge + + +@pytest.mark.parametrize('desc,is_securities_act,account_id', TEST_DATA_SECURITIES_ACT) +def test_securities_act_account(session, desc, is_securities_act, account_id): + """Assert that crown_charge_account behaves as expected.""" + sec_act_account = AccountBcolId.securities_act_account(account_id) + assert sec_act_account == is_securities_act diff --git a/ppr-api/tests/unit/models/test_client_code.py b/ppr-api/tests/unit/models/test_client_code.py index 54ab63366..6a3c4ca7e 100644 --- a/ppr-api/tests/unit/models/test_client_code.py +++ b/ppr-api/tests/unit/models/test_client_code.py @@ -26,18 +26,19 @@ ('Exists', True, '200000000'), ('Does not exist', False, '12345') ] -# testdata pattern is ({description}, {account_id}, {results_size}, {crown_charge}) +# testdata pattern is ({description}, {account_id}, {results_size}, {crown_charge}, {securities_act}) TEST_DATA_ACCOUNT_NUMBER = [ - ('CC account with bcol number mapping', 'PS12345', 2, True), - ('Non CC account with bcol number mapping', 'PS00001', 1, False), - ('Account with no bcol number mapping', 'PS1234X', 0, False) + ('CC account with bcol number mapping', 'PS12345', 2, True, False), + ('Non CC account with bcol number mapping', 'PS00001', 1, False, False), + ('Securities Act account with bcol number mapping', 'PS00002', 1, False, True), + ('Account with no bcol number mapping', 'PS1234X', 0, False, False) ] # testdata pattern is ({description}, {results_size}, {search_value}) TEST_DATA_BRANCH_CODE = [ ('No results exact', 0, '000'), ('No results start 3', 0, '998'), ('Results start 3', 4, '999'), - ('No results start 4', 0, '9998'), + ('No results start 4', 0, '9997'), ('Results start 4', 4, '9999'), ('Results start 5', 4, '99990'), ('Results start 6', 4, '999900'), @@ -49,7 +50,7 @@ ('Code exists 3 digits', 4, '999', False), ('No code exists 3 digits', 0, '998', False), ('Code exists 4 digits', 4, '9999', False), - ('No code exists 4', 0, '9998', False), + ('No code exists 4', 0, '9997', False), ('Code exists 5 digits', 4, '99990', False), ('Name exists', 4, 'rbc royal bank', False), ('Name does not exist', 0, 'XXX royal bank', False), @@ -139,10 +140,10 @@ def test_find_by_code_start(session, desc, results_size, search_value): assert not parties -@pytest.mark.parametrize('desc,account_id,results_size,crown_charge', TEST_DATA_ACCOUNT_NUMBER) -def test_find_by_account_id(session, desc, account_id, results_size, crown_charge): +@pytest.mark.parametrize('desc,account_id,results_size,crown_charge,securities_act', TEST_DATA_ACCOUNT_NUMBER) +def test_find_by_account_id(session, desc, account_id, results_size, crown_charge, securities_act): """Assert that find client parties by account id contains all expected elements.""" - parties = ClientCode.find_by_account_id(account_id, crown_charge) + parties = ClientCode.find_by_account_id(account_id, crown_charge, securities_act) if results_size > 0: assert parties assert len(parties) >= results_size diff --git a/ppr-api/tests/unit/models/test_financing_statement.py b/ppr-api/tests/unit/models/test_financing_statement.py index d2cfac265..f2a6ed9ca 100644 --- a/ppr-api/tests/unit/models/test_financing_statement.py +++ b/ppr-api/tests/unit/models/test_financing_statement.py @@ -26,27 +26,47 @@ from ppr_api.exceptions import BusinessException +SECURITIES_ACT_NOTICES = [ + { + 'securitiesActNoticeType': 'LIEN', + 'effectiveDateTime': '2024-04-22T06:59:59+00:00', + 'description': 'DETAIL DESC', + 'securitiesActOrders': [ + { + 'courtOrder': True, + 'courtName': 'name', + 'courtRegistry': 'registry', + 'fileNumber': 'file', + 'orderDate': '2024-04-22T06:59:59+00:00', + 'effectOfOrder': 'effect' + } + ] + } +] # testdata pattern is ({registration type}, {account ID}, {create draft}) TEST_REGISTRATION_DATA = [ ('SA', 'PS12345', False), ('SA', 'PS12345', True), + ('SE', 'PS00002', True), ('RL', 'PS12345', False), ('OT', 'PS12345', False), ('SA', None, False) ] -# testdata pattern is ({description}, {registration number}, {account ID}, {http status}, {is staff}, {is create}) +# testdata pattern is ({description}, {registration number}, {reg_type}, {account ID}, {http status}, {is staff}, +# {is create}) TEST_REGISTRATION_NUMBER_DATA = [ - ('Valid', 'TEST0001', 'PS12345', HTTPStatus.OK, False, True), - ('Valid added from another account', 'TEST0019', 'PS12345', HTTPStatus.OK, False, True), - ('Invalid reg num', 'TESTXXXX', 'PS12345', HTTPStatus.NOT_FOUND, False, True), - ('Mismatch account id non-staff', 'TEST0001', 'PS1234X', HTTPStatus.UNAUTHORIZED, False, True), - ('Expired non-staff', 'TEST0013', 'PS12345', HTTPStatus.BAD_REQUEST, False, True), - ('Discharged non-staff', 'TEST0014', 'PS12345', HTTPStatus.BAD_REQUEST, False, True), - ('Mismatch staff', 'TEST0001', 'PS1234X', HTTPStatus.OK, True, True), - ('Expired staff not create', 'TEST0013', 'PS12345', HTTPStatus.OK, True, False), - ('Expired staff create', 'TEST0013', 'PS12345', HTTPStatus.BAD_REQUEST, True, True), - ('Discharged staff not create', 'TEST0014', 'PS12345', HTTPStatus.OK, True, False), - ('Discharged staff create', 'TEST0014', 'PS12345', HTTPStatus.BAD_REQUEST, True, True), + ('Valid', 'TEST0001', 'SA', 'PS12345', HTTPStatus.OK, False, True), + ('Valid added from another account', 'TEST0019', 'SA', 'PS12345', HTTPStatus.OK, False, True), + ('Invalid reg num', 'TESTXXXX', 'SA', 'PS12345', HTTPStatus.NOT_FOUND, False, True), + ('Mismatch account id non-staff', 'TEST0001', 'SA', 'PS1234X', HTTPStatus.UNAUTHORIZED, False, True), + ('Expired non-staff', 'TEST0013', 'SA', 'PS12345', HTTPStatus.BAD_REQUEST, False, True), + ('Discharged non-staff', 'TEST0014', 'SA', 'PS12345', HTTPStatus.BAD_REQUEST, False, True), + ('Mismatch staff', 'TEST0001', 'SA', 'PS1234X', HTTPStatus.OK, True, True), + ('Expired staff not create', 'TEST0013', 'SA', 'PS12345', HTTPStatus.OK, True, False), + ('Expired staff create', 'TEST0013', 'SA', 'PS12345', HTTPStatus.BAD_REQUEST, True, True), + ('Discharged staff not create', 'TEST0014', 'SA', 'PS12345', HTTPStatus.OK, True, False), + ('Discharged staff create', 'TEST0014', 'SA', 'PS12345', HTTPStatus.BAD_REQUEST, True, True), + ('Valid SE', 'TEST0022', 'SE', 'PS00002', HTTPStatus.OK, False, True) ] # testdata pattern is ({description}, {registration number}, {type}, {debtor name}, {is valid}) TEST_DEBTOR_NAME_DATA = [ @@ -93,7 +113,13 @@ def test_save(session, reg_type, account_id, create_draft): del json_data['createDateTime'] del json_data['baseRegistrationNumber'] del json_data['payment'] - del json_data['lifeInfinite'] + if reg_type == model_utils.REG_TYPE_SECURITIES_NOTICE: + json_data['lifeInfinite'] = True + del json_data['lifeYears'] + del json_data['vehicleCollateral'] + json_data['securitiesActNotices'] = copy.deepcopy(SECURITIES_ACT_NOTICES) + else: + del json_data['lifeInfinite'] del json_data['expiryDate'] del json_data['documentId'] if reg_type != model_utils.REG_TYPE_REPAIRER_LIEN: @@ -101,7 +127,8 @@ def test_save(session, reg_type, account_id, create_draft): del json_data['surrenderDate'] if reg_type != model_utils.REG_TYPE_SECURITY_AGREEMENT: del json_data['trustIndenture'] - del json_data['generalCollateral'] + if reg_type != model_utils.REG_TYPE_SECURITIES_NOTICE: + del json_data['generalCollateral'] if reg_type == model_utils.REG_TYPE_OTHER: json_data['otherTypeDescription'] = 'Other ACT' @@ -126,8 +153,14 @@ def test_save(session, reg_type, account_id, create_draft): assert result['registeringParty'] assert result['debtors'][0] assert result['securedParties'][0] - assert result['vehicleCollateral'][0] - if reg_type == model_utils.REG_TYPE_SECURITY_AGREEMENT: + if reg_type != model_utils.REG_TYPE_SECURITIES_NOTICE: + assert result['vehicleCollateral'][0] + else: + assert not result.get('vehicleCollateral') + assert result.get('securitiesActNotices') + for notice in result.get('securitiesActNotices'): + assert notice.get('securitiesActOrders') + if reg_type in (model_utils.REG_TYPE_SECURITY_AGREEMENT, model_utils.REG_TYPE_SECURITIES_NOTICE): assert result['generalCollateral'][0] assert 'documentId' not in result if reg_type == model_utils.REG_TYPE_OTHER: @@ -200,14 +233,14 @@ def test_find_by_financing_id(session): assert json_data['trustIndenture'] -@pytest.mark.parametrize('desc,reg_number,account_id,status,staff,create', TEST_REGISTRATION_NUMBER_DATA) -def test_find_by_registration_number(session, desc, reg_number, account_id, status, staff, create): +@pytest.mark.parametrize('desc,reg_number,reg_type,account_id,status,staff,create', TEST_REGISTRATION_NUMBER_DATA) +def test_find_by_registration_number(session, desc, reg_number, reg_type, account_id, status, staff, create): """Assert that a fetch financing statement by registration number works as expected.""" if status == HTTPStatus.OK: statement = FinancingStatement.find_by_registration_number(reg_number, account_id, staff, create) assert statement result = statement.json - assert result['type'] == 'SA' + assert result['type'] == reg_type assert result['baseRegistrationNumber'] == reg_number assert result['registeringParty'] assert result['createDateTime'] @@ -215,13 +248,19 @@ def test_find_by_registration_number(session, desc, reg_number, account_id, stat assert result['securedParties'][0] if reg_number == 'TEST0001': assert result['vehicleCollateral'][0] - assert result['expiryDate'] - assert result['lifeYears'] + if reg_type != 'SE': + assert result['expiryDate'] + assert result['lifeYears'] if reg_number == 'TEST0001': assert result['generalCollateral'][0] assert result['trustIndenture'] if statement.current_view_json and reg_number == 'TEST0001': assert result['courtOrderInformation'] + if reg_type == 'SE': + assert result.get('lifeInfinite') + assert result['generalCollateral'][0] + assert result.get('securitiesActNotices') + assert result['securitiesActNotices'][0].get('securitiesActOrders') else: with pytest.raises(BusinessException) as request_err: FinancingStatement.find_by_registration_number(reg_number, account_id, staff, create) diff --git a/ppr-api/tests/unit/models/test_registration.py b/ppr-api/tests/unit/models/test_registration.py index 12a4bfb2f..8e11d8dfe 100644 --- a/ppr-api/tests/unit/models/test_registration.py +++ b/ppr-api/tests/unit/models/test_registration.py @@ -128,6 +128,12 @@ ('Mismatch registration numbers staff', 'TEST00R5', 'PS12345', HTTPStatus.OK, True, 'TEST0001'), ('Discharged staff', 'TEST0D14', 'PS12345', HTTPStatus.OK, True, 'TEST0014') ] +# testdata pattern is ({description}, {reg_id}, {reg_number}, {reg_type}, {account_id}, {http status}) +TEST_REGISTRATION_NUMBER_DATA_FS = [ + ('Valid SA', 200000000, 'TEST0001', 'SA', 'PS12345', HTTPStatus.OK), + ('Valid SE', 200000038, 'TEST0022', 'SE', 'PS00002', HTTPStatus.OK), + ('Invalid Reg Num', 0, 'TESTXXXX', 'SA', 'PS00002', HTTPStatus.NOT_FOUND) +] # testdata pattern is ({description}, {account ID}, {collapse results}, {user_added_reg_num}, {user_removed_reg_num}) TEST_ACCOUNT_REGISTRATION_DATA = [ ('Default registration format user added', 'PS12345', False, 'TEST0019', None), @@ -440,16 +446,28 @@ def test_find_by_registration_number(session, desc, reg_number, account_id, stat print(request_err.value.error) -def test_find_by_registration_num_fs(session): +@pytest.mark.parametrize('desc,reg_id,reg_number,reg_type, account_id,status', TEST_REGISTRATION_NUMBER_DATA_FS) +def test_find_by_registration_num_fs(session, desc, reg_id, reg_number, reg_type, account_id, status): """Assert that find a financing statement by registration number contains all expected elements.""" - registration = Registration.find_by_registration_number('TEST0001', 'PS12345', False) - assert registration - assert registration.id == 200000000 - assert registration.registration_num == 'TEST0001' - assert registration.registration_type - assert registration.registration_ts - assert registration.account_id - assert registration.client_reference_id + if status == HTTPStatus.OK: + registration = Registration.find_by_registration_number(reg_number, account_id, False) + assert registration + assert registration.id == reg_id + assert registration.registration_num == reg_number + assert registration.registration_type == reg_type + assert registration.registration_ts + assert registration.account_id == account_id + assert registration.client_reference_id + if reg_type == 'SE': + assert registration.securities_act_notices + for notice in registration.securities_act_notices: + assert notice.securities_act_orders + else: + with pytest.raises(BusinessException) as request_err: + Registration.find_by_registration_number(reg_number, account_id, False) + # check + assert request_err + assert request_err.value.status_code == status def test_find_by_registration_num_ds(session): diff --git a/ppr-api/tests/unit/models/test_securities_act_notice.py b/ppr-api/tests/unit/models/test_securities_act_notice.py new file mode 100644 index 000000000..f9360d0e6 --- /dev/null +++ b/ppr-api/tests/unit/models/test_securities_act_notice.py @@ -0,0 +1,99 @@ +# Copyright © 2019 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. + +"""Tests to assure the SecuritiesActNotice Model. + +Test-Suite to ensure that the SecuritiesActNotice Model is working as expected. +""" +import pytest + +from ppr_api.models import SecuritiesActNotice, utils as model_utils + + +# testdata pattern is ({description}, {exists}, {sec_act_id}, {sec_act_type}, {type_desc}) +TEST_DATA_ID = [ + ('Exists', True, 200000000, 'PRESERVATION', 'SECURITIES ACT NOTICE OF PRESERVATION ORDER'), + ('Does not exist', False, 300000000, None, None) +] +# testdata pattern is ({description}, {exists}, {id}, {sec_act_type}, {type_desc}) +TEST_DATA_REG_ID = [ + ('Exists', True, 200000038, 'PRESERVATION', 'SECURITIES ACT NOTICE OF PRESERVATION ORDER'), + ('Does not exist', False, 300000000, None, None) +] + + +@pytest.mark.parametrize('desc,exists,sec_act_id,sec_act_type, type_desc', TEST_DATA_ID) +def test_find_by_id(session, desc, exists, sec_act_id, sec_act_type, type_desc): + """Assert that find securities act by id contains all expected elements.""" + sec_act: SecuritiesActNotice = SecuritiesActNotice.find_by_id(sec_act_id) + if exists: + assert sec_act + assert sec_act.id == sec_act_id + assert sec_act.registration_id + assert sec_act.effective_ts + assert sec_act.detail_description + assert sec_act.securities_act_type == sec_act_type + assert sec_act.sec_act_type + assert sec_act.securities_act_orders + sec_act_json = sec_act.json + assert sec_act_json.get('securitiesActNoticeType') == sec_act_type + assert sec_act_json.get('effectiveDateTime') + assert sec_act_json.get('description') + assert sec_act_json.get('registrationDescription') == type_desc + assert sec_act_json.get('securitiesActOrders') + else: + assert not sec_act + + +@pytest.mark.parametrize('desc,exists,reg_id,sec_act_type,type_desc', TEST_DATA_REG_ID) +def test_find_by_registration_id(session, desc, exists, reg_id, sec_act_type, type_desc): + """Assert that find securities act by registration id contains all expected elements.""" + sec_acts = SecuritiesActNotice.find_by_registration_id(reg_id) + if exists: + assert sec_acts + assert sec_acts[0].registration_id == reg_id + assert sec_acts[0].id + assert sec_acts[0].effective_ts + assert sec_acts[0].detail_description + assert sec_acts[0].securities_act_type == sec_act_type + assert sec_acts[0].sec_act_type + assert sec_acts[0].securities_act_orders + sec_act_json = sec_acts[0].json + assert sec_act_json.get('securitiesActNoticeType') == sec_act_type + assert sec_act_json.get('effectiveDateTime') + assert sec_act_json.get('description') + assert sec_act_json.get('registrationDescription') == type_desc + assert sec_act_json.get('securitiesActOrders') + else: + assert not sec_acts + + +def test_securities_act_json(session): + """Assert that the securities act model renders to a json format correctly.""" + now = model_utils.now_ts() + sec_act_type = 'LIEN' + description = 'DETAIL DESC' + sec_act = SecuritiesActNotice( + id=10001, + securities_act_type=sec_act_type, + effective_ts = now, + detail_description = description + ) + + sec_act_json = { + 'securitiesActNoticeType': sec_act_type, + 'effectiveDateTime': model_utils.format_ts(now), + 'description': description + } + assert sec_act.json == sec_act_json diff --git a/ppr-api/tests/unit/models/test_securities_act_order.py b/ppr-api/tests/unit/models/test_securities_act_order.py new file mode 100644 index 000000000..6e043527a --- /dev/null +++ b/ppr-api/tests/unit/models/test_securities_act_order.py @@ -0,0 +1,114 @@ +# Copyright © 2019 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. + +"""Tests to assure the SecuritiesActOrder Model. + +Test-Suite to ensure that the SecuritiesActOrder Model is working as expected. +""" +import pytest + +from ppr_api.models import SecuritiesActOrder, utils as model_utils + + +# testdata pattern is ({description}, {exists}, {order_id}, {name}, {registry}, {file}, {effect}, {order_ind}) +TEST_DATA_ID = [ + ('Exists', True, 200000000, 'COURT NAME', 'COURT REGISTRY', 'FILE# 00001', 'UNIT TEST', 'Y'), + ('Does not exist', False, 300000000, None, None, None, None, None) +] +# testdata pattern is ({description}, {exists}, {notice_id}, {name}, {registry}, {file}, {effect}, {order_ind}) +TEST_DATA_NOTICE_ID = [ + ('Exists', True, 200000000, 'COURT NAME', 'COURT REGISTRY', 'FILE# 00001', 'UNIT TEST', 'Y'), + ('Does not exist', False, 300000000, None, None, None, None, None) +] + + +@pytest.mark.parametrize('desc,exists,order_id,name,registry,file,effect,order_ind', TEST_DATA_ID) +def test_find_by_id(session, desc, exists, order_id, name, registry, file, effect, order_ind): + """Assert that find securities act order by id contains all expected elements.""" + order: SecuritiesActOrder = SecuritiesActOrder.find_by_id(order_id) + if exists: + assert order + assert order.id == order_id + assert order.securities_act_notice_id + assert order.registration_id + assert order.court_name == name + assert order.court_registry == registry + assert order.order_date + assert order.file_number == file + assert order.effect_of_order == effect + assert order.court_order_ind == order_ind + order_json = order.json + assert order_json.get('courtOrder') + assert order_json.get('orderDate') + assert order_json.get('courtName') == name + assert order_json.get('courtRegistry') == registry + assert order_json.get('fileNumber') == file + assert order_json.get('effectOfOrder') == effect + else: + assert not order + + +@pytest.mark.parametrize('desc,exists,notice_id,name,registry,file,effect,order_ind', TEST_DATA_NOTICE_ID) +def test_find_by_notice_id(session, desc, exists, notice_id, name, registry, file, effect, order_ind): + """Assert that find securities act order by notice id contains all expected elements.""" + orders = SecuritiesActOrder.find_by_notice_id(notice_id) + if exists: + assert orders + assert orders[0].id + assert orders[0].securities_act_notice_id == notice_id + assert orders[0].registration_id + assert orders[0].court_name == name + assert orders[0].court_registry == registry + assert orders[0].order_date + assert orders[0].file_number == file + assert orders[0].effect_of_order == effect + assert orders[0].court_order_ind == order_ind + order_json = orders[0].json + assert order_json.get('courtOrder') + assert order_json.get('orderDate') + assert order_json.get('courtName') == name + assert order_json.get('courtRegistry') == registry + assert order_json.get('fileNumber') == file + assert order_json.get('effectOfOrder') == effect + else: + assert not orders + + +def test_securities_act_json(session): + """Assert that the securities act model renders to a json format correctly.""" + now = model_utils.now_ts() + name = 'name' + registry = 'registry' + file = 'file' + effect = 'effect' + order = SecuritiesActOrder( + id=10001, + securities_act_notice_id=10001, + court_order_ind='Y', + court_name=name, + order_date = now, + court_registry = registry, + file_number=file, + effect_of_order=effect + ) + + order_json = { + 'courtOrder': True, + 'courtName': name, + 'courtRegistry': registry, + 'fileNumber': file, + 'orderDate': model_utils.format_ts(now), + 'effectOfOrder': effect + } + assert order.json == order_json diff --git a/ppr-api/tests/unit/utils/test_financing_validator.py b/ppr-api/tests/unit/utils/test_financing_validator.py index 97551a572..37683b0fd 100644 --- a/ppr-api/tests/unit/utils/test_financing_validator.py +++ b/ppr-api/tests/unit/utils/test_financing_validator.py @@ -85,6 +85,101 @@ 'trustIndenture': False, 'lifeInfinite': False } +FINANCING_SE = { + 'type': 'SE', + 'clientReferenceId': 'A-00000402', + 'documentId': '1234567', + 'authorizationReceived': True, + 'registeringParty': { + 'code': '99980001' + }, + 'securedParties': [ + { + 'code': '99980001' + } + ], + 'debtors': [ + { + 'businessName': 'Brown Window Cleaning Inc.', + 'address': { + 'street': '1234 Blanshard St', + 'city': 'Victoria', + 'region': 'BC', + 'country': 'CA', + 'postalCode': 'V8S 3J5' + }, + 'emailAddress': 'csmith@bwc.com' + } + ], + 'generalCollateral': [ + { + 'description': 'Fridges and stoves. Proceeds: Accts Receivable.' + } + ], + 'lifeInfinite': True +} +SE_SP_INVALID = [ + { + 'code': '99990001' + }, + { + 'businessName': 'BANK OF BRITISH COLUMBIA', + 'address': { + 'street': '3720 BEACON AVENUE', + 'city': 'SIDNEY', + 'region': 'BC', + 'country': 'CA', + 'postalCode': 'V7R 1R7' + } + } +] +SE_SP_INVALID1 = [ + { + 'businessName': 'BANK OF BRITISH COLUMBIA', + 'address': { + 'street': '3720 BEACON AVENUE', + 'city': 'SIDNEY', + 'region': 'BC', + 'country': 'CA', + 'postalCode': 'V7R 1R7' + } + } +] +SE_SP_INVALID2 = [ + { + 'code': '99990001' + } +] +SE_RP_INVALID_1 = { + 'businessName': 'BANK OF BRITISH COLUMBIA', + 'address': { + 'street': '3720 BEACON AVENUE', + 'city': 'SIDNEY', + 'region': 'BC', + 'country': 'CA', + 'postalCode': 'V7R 1R7' + } +} +SE_RP_INVALID_2 = { + 'code': '99990001' +} +SECURITIES_ACT_NOTICES = [ + { + 'securitiesActNoticeType': 'LIEN', + 'effectiveDateTime': '2024-04-22T06:59:59+00:00', + 'description': 'DETAIL DESC', + 'securitiesActOrders': [ + { + 'courtOrder': True, + 'courtName': 'court name', + 'courtRegistry': 'registry', + 'fileNumber': 'filenumber', + 'orderDate': '2024-04-22T06:59:59+00:00', + 'effectOfOrder': 'effect' + } + ] + } +] DESC_VALID = 'Valid' DESC_INCLUDES_GC = 'Includes general collateral' @@ -104,6 +199,22 @@ DESC_MISSING_AC = 'Missing authorizaton received' DESC_INVALID_AC = 'Invalid authorizaton received' +# testdata pattern is ({description}, {valid}, {account_id}, {registering}, {secured}, {notices}, {message content}) +TEST_SE_DATA = [ + (DESC_VALID, True, 'PS00002', None, None, SECURITIES_ACT_NOTICES, None), + ('Invalid account', False, 'PS12345', None, None, SECURITIES_ACT_NOTICES, validator.SE_ACCESS_INVALID), + ('Invalid notices', False, 'PS00002', None, None, None, validator.SE_NOTICES_MISSING), + ('Invalid 2 secured parties', False, 'PS00002', None, SE_SP_INVALID, SECURITIES_ACT_NOTICES, + validator.SE_SECURED_COUNT_INVALID), + ('Invalid secured party no code', False, 'PS00002', None, SE_SP_INVALID1, SECURITIES_ACT_NOTICES, + validator.SE_SP_MISSING_CODE), + ('Invalid secured party code', False, 'PS00002', None, SE_SP_INVALID2, SECURITIES_ACT_NOTICES, + validator.SE_SP_INVALID_CODE), + ('Invalid registering party no code', False, 'PS00002', SE_RP_INVALID_1, None, SECURITIES_ACT_NOTICES, + validator.SE_RP_MISSING_CODE), + ('Invalid registering party code', False, 'PS00002', SE_RP_INVALID_2, None, SECURITIES_ACT_NOTICES, + validator.SE_RP_INVALID_CODE) +] # testdata pattern is ({description}, {valid}, {lien_amount}, {surrender_date}, {message content}) TEST_RL_DATA = [ (DESC_VALID, True, '1000', 'valid', None), @@ -116,8 +227,6 @@ (DESC_MISSING_VC, False, '1000', 'valid', validator.VC_REQUIRED), (DESC_VC_MH, False, '1000', 'valid', validator.VC_MH_NOT_ALLOWED) ] - - # testdata pattern is ({description}, {valid}, {reg_type}) TEST_EXCLUDED_TYPE_DATA = [ ('Type not allowed', False, 'SS'), @@ -127,8 +236,6 @@ ('Type not allowed', False, 'HR'), ('Type not allowed', False, 'MI') ] - - # testdata pattern is ({description}, {valid}, {reg_type}, {message content}) TEST_FR_LT_MH_MN_DATA = [ (DESC_VALID, True, 'FR', None), @@ -158,7 +265,6 @@ (DESC_EXCLUDES_LY, False, 'LT', validator.LY_NOT_ALLOWED), (DESC_EXCLUDES_LY, False, 'MH', validator.LY_NOT_ALLOWED) ] - # testdata pattern is ({description}, {valid}, {message content}) TEST_PPSA_DATA = [ (DESC_VALID, True, None), @@ -168,13 +274,11 @@ (DESC_INCLUDES_LA, False, validator.LA_NOT_ALLOWED), (DESC_INCLUDES_SD, False, validator.SD_NOT_ALLOWED) ] - # testdata pattern is ({description}, {valid}, {message content}) TEST_AUTHORIZATION_DATA = [ (DESC_MISSING_AC, False, validator.AUTHORIZATION_INVALID), (DESC_INVALID_AC, False, validator.AUTHORIZATION_INVALID) ] - # testdata pattern is ({description}, {valid}, {message content}) TEST_CROWN_DATA = [ (DESC_VALID, True, None), @@ -185,7 +289,6 @@ (DESC_INCLUDES_OT_DESC, False, validator.OT_NOT_ALLOWED), (DESC_MISSING_OT_DESC, False, validator.OT_MISSING_DESCRIPTION) ] - # testdata pattern is ({description}, {valid}, {reg_type}, {message content}) TEST_MD_PT_SC_DATA = [ (DESC_VALID, True, 'MD', None), @@ -204,7 +307,6 @@ (DESC_MISSING_GC, False, 'SV', validator.GC_REQUIRED), (DESC_INCLUDES_VC, True, 'SV', None) ] - # testdata pattern is ({description}, {valid}, {reg_type}, {message content}) TEST_FL_FA_FS_HN_WL_DATA = [ (DESC_VALID, True, 'FL', None), @@ -217,13 +319,14 @@ (DESC_MISSING_GC, False, 'FS', validator.GC_REQUIRED), (DESC_MISSING_GC, False, 'HN', validator.GC_REQUIRED), (DESC_MISSING_GC, False, 'WL', validator.GC_REQUIRED), + (DESC_MISSING_GC, False, 'SE', validator.GC_REQUIRED), (DESC_INCLUDES_VC, False, 'FL', validator.VC_NOT_ALLOWED), (DESC_INCLUDES_VC, False, 'FA', validator.VC_NOT_ALLOWED), (DESC_INCLUDES_VC, False, 'FS', validator.VC_NOT_ALLOWED), (DESC_INCLUDES_VC, False, 'HN', validator.VC_NOT_ALLOWED), + (DESC_INCLUDES_VC, False, 'SE', validator.VC_NOT_ALLOWED), (DESC_INCLUDES_VC, True, 'WL', None) ] - # testdata pattern is ({description}, {valid}, {reg_type}, {message content}) TEST_MISC_DATA = [ (DESC_VALID, True, 'HN', None), @@ -236,14 +339,36 @@ (DESC_INFINITY_INVALID, False, 'MN', validator.LI_INVALID), (DESC_INFINITY_INVALID, False, 'PN', validator.LI_INVALID), (DESC_INFINITY_INVALID, False, 'WL', validator.LI_INVALID), + (DESC_INFINITY_INVALID, False, 'SE', validator.LI_INVALID), (DESC_EXCLUDES_LY, False, 'HN', validator.LY_NOT_ALLOWED), (DESC_EXCLUDES_LY, False, 'ML', validator.LY_NOT_ALLOWED), (DESC_EXCLUDES_LY, False, 'MN', validator.LY_NOT_ALLOWED), (DESC_EXCLUDES_LY, False, 'PN', validator.LY_NOT_ALLOWED), + (DESC_EXCLUDES_LY, False, 'SE', validator.LY_NOT_ALLOWED), (DESC_EXCLUDES_LY, False, 'WL', validator.LY_NOT_ALLOWED) ] +@pytest.mark.parametrize('desc,valid,account_id,registering,secured,notices,message_content', TEST_SE_DATA) +def test_validate_se(session, desc, valid, account_id, registering, secured, notices, message_content): + """Assert that financing statement SE registration type validation works as expected.""" + # setup + json_data = copy.deepcopy(FINANCING_SE) + if notices: + json_data['securitiesActNotices'] = notices + if registering: + json_data['registeringParty'] = registering + if secured: + json_data['securedParties'] = secured + error_msg = validator.validate(json_data, account_id) + if valid: + assert error_msg == '' + elif message_content: + # print(error_msg) + assert error_msg != '' + assert error_msg.find(message_content) != -1 + + @pytest.mark.parametrize('desc,valid,lien_amount,surrender_date,message_content', TEST_RL_DATA) def test_validate_rl(session, desc, valid, lien_amount, surrender_date, message_content): """Assert that financing statement RL registration type validation works as expected.""" @@ -270,7 +395,7 @@ def test_validate_rl(session, desc, valid, lien_amount, surrender_date, message_ elif desc == DESC_VC_MH: json_data['vehicleCollateral'][0]['type'] = 'MH' - error_msg = validator.validate(json_data) + error_msg = validator.validate(json_data, 'PS12345') if valid: assert error_msg == '' elif message_content: @@ -285,7 +410,7 @@ def test_validate_excluded_type(session, desc, valid, reg_type): # setup json_data = copy.deepcopy(FINANCING) json_data['type'] = reg_type - error_msg = validator.validate(json_data) + error_msg = validator.validate(json_data, 'PS12345') if valid: assert error_msg == '' else: @@ -314,7 +439,7 @@ def test_validate_fr_lt_mh_mn(session, desc, valid, reg_type, message_content): elif desc != DESC_VC_NOT_MH: json_data['vehicleCollateral'][0]['type'] = 'MH' - error_msg = validator.validate(json_data) + error_msg = validator.validate(json_data, 'PS12345') if valid: assert error_msg == '' elif message_content: @@ -359,7 +484,7 @@ def test_validate_ppsa(session, desc, valid, message_content): json_data['surrenderDate'] = '2030-06-15T00:00:00-07:00' # print('REG TYPE: ' + str(json_data['type'])) - error_msg = validator.validate(json_data) + error_msg = validator.validate(json_data, 'PS12345') if valid: assert error_msg == '' elif message_content: @@ -382,7 +507,7 @@ def test_validate_md_pt_sc(session, desc, valid, reg_type, message_content): json_data['lifeInfinite'] = True del json_data['lifeYears'] - error_msg = validator.validate(json_data) + error_msg = validator.validate(json_data, 'PS12345') if valid: assert error_msg == '' elif message_content: @@ -421,7 +546,7 @@ def test_validate_crown(session, desc, valid, message_content): del json_data['vehicleCollateral'] # print('REG TYPE: ' + str(json_data['type'])) - error_msg = validator.validate(json_data) + error_msg = validator.validate(json_data, 'PS12345') if valid: assert error_msg == '' elif message_content: @@ -444,7 +569,7 @@ def test_validate_fl_fa_fs_hn_wl(session, desc, valid, reg_type, message_content if desc != DESC_INCLUDES_VC: del json_data['vehicleCollateral'] - error_msg = validator.validate(json_data) + error_msg = validator.validate(json_data, 'PS12345') if valid: assert error_msg == '' elif message_content: @@ -472,7 +597,7 @@ def test_validate_misc(session, desc, valid, reg_type, message_content): del json_data['generalCollateral'] json_data['vehicleCollateral'][0]['type'] = 'MH' - error_msg = validator.validate(json_data) + error_msg = validator.validate(json_data, 'PS12345') if valid: assert error_msg == '' elif message_content: @@ -492,7 +617,7 @@ def test_validate_authorization(session, desc, valid, message_content): json_data['authorizationReceived'] = False # test - error_msg = validator.validate(json_data) + error_msg = validator.validate(json_data, 'PS12345') if valid: assert error_msg == '' elif message_content: @@ -505,7 +630,7 @@ def test_validate_sc_ap(session): # setup json_data = copy.deepcopy(FINANCING) json_data['vehicleCollateral'][0]['type'] = 'AP' - error_msg = validator.validate(json_data) + error_msg = validator.validate(json_data, 'PS12345') # print(error_msg) assert error_msg != '' assert error_msg.find(validator.VC_AP_NOT_ALLOWED) != -1