diff --git a/jobs/payment-jobs/flags.json b/jobs/payment-jobs/flags.json new file mode 100644 index 000000000..870b306f4 --- /dev/null +++ b/jobs/payment-jobs/flags.json @@ -0,0 +1,8 @@ +{ + "flagValues": { + "string-flag": "a string value", + "bool-flag": true, + "integer-flag": 10, + "enable-eft-payment-method": true + } +} diff --git a/jobs/payment-jobs/requirements.txt b/jobs/payment-jobs/requirements.txt index f8740c835..f21837ebb 100644 --- a/jobs/payment-jobs/requirements.txt +++ b/jobs/payment-jobs/requirements.txt @@ -1,5 +1,5 @@ -e git+https://github.com/bcgov/sbc-common-components.git@b93585ea3ac273b9e51c4dd5ddbc8190fd95da6a#egg=sbc_common_components&subdirectory=python --e git+https://github.com/bcgov/sbc-pay.git@2e4373b8a9280d64737f7b6ed172831ba4e541d2#egg=pay_api&subdirectory=pay-api +-e git+https://github.com/bcgov/sbc-pay.git@90fc03c42aa6e987974398ad3dbcf35b5b21a6c5#egg=pay_api&subdirectory=pay-api Flask-Caching==2.0.2 Flask-Migrate==2.7.0 Flask-Moment==1.0.5 diff --git a/jobs/payment-jobs/requirements/dev.txt b/jobs/payment-jobs/requirements/dev.txt index 3a92341b2..0fe0a755f 100644 --- a/jobs/payment-jobs/requirements/dev.txt +++ b/jobs/payment-jobs/requirements/dev.txt @@ -7,6 +7,7 @@ pytest-mock requests pyhamcrest pytest-cov +Faker # Lint and code style flake8==5.0.4 diff --git a/jobs/payment-jobs/tasks/statement_notification_task.py b/jobs/payment-jobs/tasks/statement_notification_task.py index a62b7c561..25fee2a62 100644 --- a/jobs/payment-jobs/tasks/statement_notification_task.py +++ b/jobs/payment-jobs/tasks/statement_notification_task.py @@ -21,10 +21,12 @@ from pay_api.models.statement import Statement as StatementModel from pay_api.models.statement_recipients import StatementRecipients as StatementRecipientsModel from pay_api.services.oauth_service import OAuthService -from pay_api.utils.enums import AuthHeaderType, ContentType, NotificationStatus +from pay_api.services import Statement as StatementService +from pay_api.services.flags import flags +from pay_api.utils.enums import AuthHeaderType, ContentType, NotificationStatus, PaymentMethod from utils.auth import get_token - +from utils.mailer import publish_statement_notification ENV = Environment(loader=FileSystemLoader('.'), autoescape=True) @@ -62,7 +64,7 @@ def send_notifications(cls): statement.notification_status_code = NotificationStatus.PROCESSING.value statement.notification_date = datetime.now() statement.commit() - payment_account = PaymentAccountModel.find_by_id(statement.payment_account_id) + payment_account: PaymentAccountModel = PaymentAccountModel.find_by_id(statement.payment_account_id) recipients = StatementRecipientsModel.find_all_recipients_for_payment_id(statement.payment_account_id) if len(recipients) < 1: current_app.logger.info(f'No recipients found for statement: ' @@ -78,14 +80,27 @@ def send_notifications(cls): params['frequency'] = statement.frequency.lower() # logic changed https://github.com/bcgov/entity/issues/4809 # params.update({'url': params['url'].replace('orgId', payment_account.auth_account_id)}) + + notification_success = True + eft_enabled = flags.is_on('enable-eft-payment-method', default=False) try: - notify_response = cls.send_email(token, to_emails, template.render(params)) + if not payment_account.payment_method == PaymentMethod.EFT.value: + notification_success = cls.send_email(token, to_emails, template.render(params)) + elif eft_enabled: # This statement template currently only used for EFT + result = StatementService.get_summary(payment_account.auth_account_id) + notification_success = publish_statement_notification(payment_account, statement, + result['total_due'], to_emails) + else: # EFT not enabled - mark skip - shouldn't happen, but safeguard for manual data injection + statement.notification_status_code = NotificationStatus.SKIP.value + statement.notification_date = datetime.now() + statement.commit() + continue except Exception as e: # NOQA # pylint:disable=broad-except current_app.logger.error(' StatementSettings: """Return Factory.""" @@ -66,7 +78,7 @@ def factory_payment( def factory_invoice(payment_account: PaymentAccount, status_code: str = InvoiceStatus.CREATED.value, corp_type_code='CP', business_identifier: str = 'CP0001234', - service_fees: float = 0.0, total=0, + service_fees: float = 0.0, total=0, paid=0, payment_method_code: str = PaymentMethod.DIRECT_PAY.value, created_on: datetime = datetime.now(), cfs_account_id: int = 0, @@ -79,6 +91,7 @@ def factory_invoice(payment_account: PaymentAccount, status_code: str = InvoiceS invoice_status_code=status_code, payment_account_id=payment_account.id, total=total, + paid=paid, created_by='test', created_on=created_on, business_identifier=business_identifier, @@ -206,6 +219,17 @@ def factory_create_eft_account(auth_account_id='1234', status=CfsAccountStatus.P return account +def factory_create_account(auth_account_id: str = '1234', payment_method_code: str = PaymentMethod.DIRECT_PAY.value, + status: str = CfsAccountStatus.PENDING.value, statement_notification_enabled: bool = True): + """Return payment account model.""" + account = PaymentAccount(auth_account_id=auth_account_id, + payment_method=payment_method_code, + name=f'Test {auth_account_id}', + statement_notification_enabled=statement_notification_enabled).save() + CfsAccount(status=status, account_id=account.id).save() + return account + + def factory_create_ejv_account(auth_account_id='1234', client: str = '112', resp_centre: str = '11111', diff --git a/jobs/payment-jobs/tests/jobs/test_statement_notification_task.py b/jobs/payment-jobs/tests/jobs/test_statement_notification_task.py index 91ed442d6..4281dafac 100644 --- a/jobs/payment-jobs/tests/jobs/test_statement_notification_task.py +++ b/jobs/payment-jobs/tests/jobs/test_statement_notification_task.py @@ -16,11 +16,302 @@ Test-Suite to ensure that the StatementNotificationTask is working as expected. """ +import decimal +from datetime import datetime +from unittest.mock import ANY, patch +import pytest +from faker import Faker +from flask import Flask +from freezegun import freeze_time +from pay_api.models import Statement, StatementInvoices +from pay_api.utils.enums import InvoiceStatus, NotificationStatus, PaymentMethod, StatementFrequency +from pay_api.utils.util import get_previous_month_and_year + +import config from tasks.statement_notification_task import StatementNotificationTask +from tasks.statement_task import StatementTask +from tests.jobs.factory import ( + factory_create_account, factory_invoice, factory_invoice_reference, factory_payment, factory_statement_recipient, + factory_statement_settings) + + +fake = Faker() +app = None + + +@pytest.fixture +def setup(): + """Initialize app with test env for testing.""" + global app + app = Flask(__name__) + app.env = 'testing' + app.config.from_object(config.CONFIGURATION['testing']) + + +def create_test_data(payment_method_code: str, payment_date: datetime, + statement_frequency: str, invoice_total: decimal = 0.00, + invoice_paid: decimal = 0.00): + """Create seed data for tests.""" + account = factory_create_account(auth_account_id='1', payment_method_code=payment_method_code) + invoice = factory_invoice(payment_account=account, created_on=payment_date, + payment_method_code=payment_method_code, status_code=InvoiceStatus.OVERDUE.value, + total=invoice_total) + inv_ref = factory_invoice_reference(invoice_id=invoice.id) + payment = factory_payment(payment_date=payment_date, invoice_number=inv_ref.invoice_number) + statement_recipient = factory_statement_recipient(auth_user_id=account.auth_account_id, + first_name=fake.first_name(), + last_name=fake.last_name(), + email=fake.email(), + payment_account_id=account.id) + + statement_settings = factory_statement_settings( + pay_account_id=account.id, + from_date=payment_date, + frequency=statement_frequency + ) + + return account, invoice, inv_ref, payment, statement_recipient, statement_settings def test_send_notifications(session): - """Test create account.""" + """Test invoke send statement notifications.""" StatementNotificationTask.send_notifications() assert True + + +@pytest.mark.parametrize('payment_method_code', [ + PaymentMethod.CASH.value, + PaymentMethod.CC.value, + PaymentMethod.DRAWDOWN.value, + PaymentMethod.EJV.value, + PaymentMethod.INTERNAL.value, + PaymentMethod.ONLINE_BANKING.value, + PaymentMethod.PAD.value +]) +def test_send_monthly_notifications(setup, session, payment_method_code): # pylint: disable=unused-argument + """Test send monthly statement notifications.""" + with app.app_context(): + # create statement, invoice, payment data for previous month + last_month, last_year = get_previous_month_and_year() + previous_month_year = datetime(last_year, last_month, 5) + + account, invoice, inv_ref, payment, \ + statement_recipient, statement_settings = create_test_data(payment_method_code, + previous_month_year, + StatementFrequency.MONTHLY.value) + + assert invoice.payment_method_code == payment_method_code + assert account.payment_method == payment_method_code + + # Generate statement for previous month - freeze time to the 1st of the current month + with freeze_time(datetime.now().replace(day=1)): + StatementTask.generate_statements() + + # Assert statements and invoice was created + statements = Statement.find_all_statements_for_account(auth_account_id=account.auth_account_id, page=1, + limit=100) + assert statements is not None + assert len(statements) == 2 # items results and page total + assert len(statements[0]) == 1 # items + invoices = StatementInvoices.find_all_invoices_for_statement(statements[0][0].id) + assert invoices is not None + assert invoices[0].invoice_id == invoice.id + + # Assert notification send_email was invoked + with patch.object(StatementNotificationTask, 'send_email', return_value=True) as mock_mailer: + with patch('tasks.statement_notification_task.get_token') as mock_get_token: + mock_get_token.return_value = 'mock_token' + StatementNotificationTask.send_notifications() + mock_get_token.assert_called_once() + # Assert token and email recipient - mock any for HTML generated + mock_mailer.assert_called_with(mock_get_token.return_value, statement_recipient.email, ANY) + + # Assert statement notification code indicates success + statement: Statement = Statement.find_by_id(statements[0][0].id) + assert statement is not None + assert statement.notification_status_code == NotificationStatus.SUCCESS.value + + +@pytest.mark.parametrize('payment_method_code', [ + PaymentMethod.CASH.value, + PaymentMethod.CC.value, + PaymentMethod.DRAWDOWN.value, + PaymentMethod.EJV.value, + PaymentMethod.INTERNAL.value, + PaymentMethod.ONLINE_BANKING.value, + PaymentMethod.PAD.value +]) +def test_send_monthly_notifications_failed(setup, session, payment_method_code): # pylint: disable=unused-argument + """Test send monthly statement notifications failure.""" + with app.app_context(): + # create statement, invoice, payment data for previous month + last_month, last_year = get_previous_month_and_year() + previous_month_year = datetime(last_year, last_month, 5) + + account, invoice, inv_ref, payment, \ + statement_recipient, statement_settings = create_test_data(payment_method_code, + previous_month_year, + StatementFrequency.MONTHLY.value) + + assert invoice.payment_method_code == payment_method_code + assert account.payment_method == payment_method_code + + # Generate statement for previous month - freeze time to the 1st of the current month + with freeze_time(datetime.now().replace(day=1)): + StatementTask.generate_statements() + + # Assert statements and invoice was created + statements = Statement.find_all_statements_for_account(auth_account_id=account.auth_account_id, page=1, + limit=100) + assert statements is not None + assert len(statements) == 2 # items results and page total + assert len(statements[0]) == 1 # items + invoices = StatementInvoices.find_all_invoices_for_statement(statements[0][0].id) + assert invoices is not None + assert invoices[0].invoice_id == invoice.id + + # Assert notification send_email was invoked + with patch.object(StatementNotificationTask, 'send_email', return_value=False) as mock_mailer: + with patch('tasks.statement_notification_task.get_token') as mock_get_token: + mock_get_token.return_value = 'mock_token' + StatementNotificationTask.send_notifications() + mock_get_token.assert_called_once() + # Assert token and email recipient - mock any for HTML generated + mock_mailer.assert_called_with(mock_get_token.return_value, statement_recipient.email, ANY) + + # Assert statement notification code indicates failed + statement: Statement = Statement.find_by_id(statements[0][0].id) + assert statement is not None + assert statement.notification_status_code == NotificationStatus.FAILED.value + + +def test_send_eft_notifications(setup, session): # pylint: disable=unused-argument + """Test send monthly EFT statement notifications.""" + with app.app_context(): + # create statement, invoice, payment data for previous month + last_month, last_year = get_previous_month_and_year() + previous_month_year = datetime(last_year, last_month, 5) + account, invoice, inv_ref, payment, \ + statement_recipient, statement_settings = create_test_data(PaymentMethod.EFT.value, + previous_month_year, + StatementFrequency.MONTHLY.value, + 351.50) + + assert invoice.payment_method_code == PaymentMethod.EFT.value + assert account.payment_method == PaymentMethod.EFT.value + + # Generate statement for previous month - freeze time to the 1st of the current month + with freeze_time(datetime.now().replace(day=1)): + StatementTask.generate_statements() + + # Assert statements and invoice was created + statements = Statement.find_all_statements_for_account(auth_account_id=account.auth_account_id, page=1, + limit=100) + assert statements is not None + assert len(statements) == 2 # items results and page total + assert len(statements[0]) == 1 # items + invoices = StatementInvoices.find_all_invoices_for_statement(statements[0][0].id) + assert invoices is not None + assert invoices[0].invoice_id == invoice.id + + # Assert notification was published to the mailer queue + with patch('tasks.statement_notification_task.publish_statement_notification') as mock_mailer: + with patch('tasks.statement_notification_task.get_token') as mock_get_token: + mock_get_token.return_value = 'mock_token' + StatementNotificationTask.send_notifications() + mock_get_token.assert_called_once() + mock_mailer.assert_called_once_with(account, statements[0][0], 351.5, statement_recipient.email) + + # Assert statement notification code indicates success + statement: Statement = Statement.find_by_id(statements[0][0].id) + assert statement is not None + assert statement.notification_status_code == NotificationStatus.SUCCESS.value + + +def test_send_eft_notifications_failure(setup, session): # pylint: disable=unused-argument + """Test send monthly EFT statement notifications failure.""" + with app.app_context(): + # create statement, invoice, payment data for previous month + last_month, last_year = get_previous_month_and_year() + previous_month_year = datetime(last_year, last_month, 5) + account, invoice, inv_ref, payment, \ + statement_recipient, statement_settings = create_test_data(PaymentMethod.EFT.value, + previous_month_year, + StatementFrequency.MONTHLY.value, + 351.50) + + assert invoice.payment_method_code == PaymentMethod.EFT.value + assert account.payment_method == PaymentMethod.EFT.value + + # Generate statement for previous month - freeze time to the 1st of the current month + with freeze_time(datetime.now().replace(day=1)): + StatementTask.generate_statements() + + # Assert statements and invoice was created + statements = Statement.find_all_statements_for_account(auth_account_id=account.auth_account_id, page=1, + limit=100) + assert statements is not None + assert len(statements) == 2 # items results and page total + assert len(statements[0]) == 1 # items + invoices = StatementInvoices.find_all_invoices_for_statement(statements[0][0].id) + assert invoices is not None + assert invoices[0].invoice_id == invoice.id + + # Assert notification was published to the mailer queue + with patch('tasks.statement_notification_task.publish_statement_notification') as mock_mailer: + mock_mailer.side_effect = Exception('Mock Exception') + with patch('tasks.statement_notification_task.get_token') as mock_get_token: + mock_get_token.return_value = 'mock_token' + StatementNotificationTask.send_notifications() + mock_get_token.assert_called_once() + mock_mailer.assert_called_once_with(account, statements[0][0], 351.5, statement_recipient.email) + + # Assert statement notification code indicates failed + statement: Statement = Statement.find_by_id(statements[0][0].id) + assert statement is not None + assert statement.notification_status_code == NotificationStatus.FAILED.value + + +def test_send_eft_notifications_ff_disabled(setup, session): # pylint: disable=unused-argument + """Test send monthly EFT statement notifications failure.""" + with app.app_context(): + # create statement, invoice, payment data for previous month + last_month, last_year = get_previous_month_and_year() + previous_month_year = datetime(last_year, last_month, 5) + account, invoice, inv_ref, payment, \ + statement_recipient, statement_settings = create_test_data(PaymentMethod.EFT.value, + previous_month_year, + StatementFrequency.MONTHLY.value, + 351.50) + + assert invoice.payment_method_code == PaymentMethod.EFT.value + assert account.payment_method == PaymentMethod.EFT.value + + # Generate statement for previous month - freeze time to the 1st of the current month + with freeze_time(datetime.now().replace(day=1)): + StatementTask.generate_statements() + + # Assert statements and invoice was created + statements = Statement.find_all_statements_for_account(auth_account_id=account.auth_account_id, page=1, + limit=100) + assert statements is not None + assert len(statements) == 2 # items results and page total + assert len(statements[0]) == 1 # items + invoices = StatementInvoices.find_all_invoices_for_statement(statements[0][0].id) + assert invoices is not None + assert invoices[0].invoice_id == invoice.id + + # Assert notification was published to the mailer queue + with patch('tasks.statement_notification_task.publish_statement_notification') as mock_mailer: + with patch('tasks.statement_notification_task.get_token') as mock_get_token: + with patch('tasks.statement_notification_task.flags.is_on', return_value=False): + mock_get_token.return_value = 'mock_token' + StatementNotificationTask.send_notifications() + mock_get_token.assert_called_once() + mock_mailer.assert_not_called() + + # Assert statement notification code indicates skipped + statement: Statement = Statement.find_by_id(statements[0][0].id) + assert statement is not None + assert statement.notification_status_code == NotificationStatus.SKIP.value diff --git a/jobs/payment-jobs/tests/services/test_flags.py b/jobs/payment-jobs/tests/services/test_flags.py new file mode 100644 index 000000000..ca96cfd5b --- /dev/null +++ b/jobs/payment-jobs/tests/services/test_flags.py @@ -0,0 +1,138 @@ +# 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. + +"""Test-Suite to ensure that the Flag Service is working as expected.""" +import pytest +from flask import Flask + +from pay_api.services import Flags + +app = None + + +@pytest.fixture +def setup(): + """Initialize app with test env for testing.""" + global app + app = Flask(__name__) + app.env = 'testing' + + +def test_flags_constructor_no_app(setup): + """Ensure that flag object can be initialized.""" + flags = Flags() + assert flags + + +def test_flags_constructor_with_app(setup): + """Ensure that extensions can be initialized.""" + with app.app_context(): + flags = Flags(app) + assert flags + assert app.extensions['featureflags'] + + +def test_init_app_dev_with_key(setup): + """Ensure that extension can be initialized with a key in dev.""" + app.config['PAY_LD_SDK_KEY'] = 'https://no.flag/avail' + + with app.app_context(): + flags = Flags() + flags.init_app(app) + assert flags + assert app.extensions['featureflags'] + assert app.extensions['featureflags'].get_sdk_key() == 'https://no.flag/avail' + + +def test_init_app_dev_no_key(setup): + """Ensure that extension can be initialized with no key in dev.""" + app.config['PAY_LD_SDK_KEY'] = None + + with app.app_context(): + flags = Flags() + flags.init_app(app) + assert flags + assert app.extensions['featureflags'] + + +def test_init_app_prod_with_key(setup): + """Ensure that extension can be initialized with a key in prod.""" + app.env = 'production' + app.config['PAY_LD_SDK_KEY'] = 'https://no.flag/avail' + + with app.app_context(): + flags = Flags() + flags.init_app(app) + assert flags + assert app.extensions['featureflags'] + assert app.extensions['featureflags'].get_sdk_key() == 'https://no.flag/avail' + + +def test_init_app_prod_no_key(setup): + """Ensure that extension can be initialized with no key in prod.""" + app.env = 'production' + app.config['PAY_LD_SDK_KEY'] = None + + with app.app_context(): + flags = Flags() + flags.init_app(app) + with pytest.raises(KeyError): + client = app.extensions['featureflags'] + assert not client + assert flags + + +@pytest.mark.parametrize('test_name,flag_name,expected', [ + ('boolean flag', 'bool-flag', True), + ('string flag', 'string-flag', 'a string value'), + ('integer flag', 'integer-flag', 10), +]) +def test_flags_read_from_json(setup, test_name, flag_name, expected): + """Ensure that is_on is TRUE when reading flags from local JSON file.""" + app.config['PAY_LD_SDK_KEY'] = 'https://no.flag/avail' + + with app.app_context(): + flags = Flags() + flags.init_app(app) + + assert flags.is_on(flag_name) + + +def test_flags_read_from_json_missing_flag(setup): + """Ensure that is_on is FALSE when reading a flag that doesn't exist from local JSON file.""" + app.config['PAY_LD_SDK_KEY'] = 'https://no.flag/avail' + + with app.app_context(): + flags = Flags() + flags.init_app(app) + flag_on = flags.is_on('missing flag') + + assert not flag_on + + +@pytest.mark.parametrize('test_name,flag_name,expected', [ + ('boolean flag', 'bool-flag', True), + ('string flag', 'string-flag', 'a string value'), + ('integer flag', 'integer-flag', 10), +]) +def test_flags_read_flag_values_from_json(setup, test_name, flag_name, expected): + """Ensure that values read from JSON == expected values when no user is passed.""" + app.config['PAY_LD_SDK_KEY'] = 'https://no.flag/avail' + + with app.app_context(): + flags = Flags() + flags.init_app(app) + val = flags.value(flag_name) + + assert val == expected diff --git a/jobs/payment-jobs/utils/mailer.py b/jobs/payment-jobs/utils/mailer.py index 72a9e3cc3..dfacd9b25 100644 --- a/jobs/payment-jobs/utils/mailer.py +++ b/jobs/payment-jobs/utils/mailer.py @@ -19,6 +19,7 @@ from flask import current_app from pay_api.models import FeeSchedule as FeeScheduleModel from pay_api.models import PaymentAccount as PaymentAccountModel +from pay_api.models import Statement as StatementModel from pay_api.services.queue_publisher import publish_response from sentry_sdk import capture_message @@ -55,3 +56,40 @@ def publish_mailer_events(message_type: str, pay_account: PaymentAccountModel, payload) capture_message('Notification to Queue failed for the Account Mailer {auth_account_id}, {msg}.'.format( auth_account_id=pay_account.auth_account_id, msg=payload), level='error') + + +def publish_statement_notification(pay_account: PaymentAccountModel, statement: StatementModel, + total_amount_owing: float, emails: str) -> bool: + """Publish payment statement notification message to the mailer queue.""" + payload = { + 'specversion': '1.x-wip', + 'type': f'bc.registry.payment.statementNotification', + 'source': f'https://api.pay.bcregistry.gov.bc.ca/v1/accounts/{pay_account.auth_account_id}', + 'id': f'{pay_account.auth_account_id}', + 'time': f'{datetime.now()}', + 'datacontenttype': 'application/json', + 'data': { + 'emailAddresses': emails, + 'accountId': pay_account.auth_account_id, + 'fromDate': f'{statement.from_date}', + 'toDate:': f'{statement.to_date}', + 'statementFrequency': statement.frequency, + 'totalAmountOwing': total_amount_owing + } + } + try: + publish_response(payload=payload, + client_name=current_app.config.get('NATS_MAILER_CLIENT_NAME'), + subject=current_app.config.get('NATS_MAILER_SUBJECT')) + except Exception as e: # pylint: disable=broad-except + current_app.logger.error(e) + current_app.logger.warning('Notification to Queue failed for the Account Mailer %s - %s', + pay_account.auth_account_id, + payload) + capture_message('Notification to Queue failed for the Account Mailer {auth_account_id}, {msg}.'.format( + auth_account_id=pay_account.auth_account_id, msg=payload), level='error') + + return False + + return True +