diff --git a/jobs/payment-jobs/invoke_jobs.py b/jobs/payment-jobs/invoke_jobs.py index 665d6d5c5..870e6def2 100755 --- a/jobs/payment-jobs/invoke_jobs.py +++ b/jobs/payment-jobs/invoke_jobs.py @@ -25,6 +25,7 @@ import config from services import oracle_db from tasks.routing_slip_task import RoutingSlipTask +from tasks.statement_due_task import StatementDueTask from utils.logger import setup_logging from pay_api.services import Flags @@ -118,6 +119,9 @@ def run(job_name, argument=None): elif job_name == 'NOTIFY_UNPAID_INVOICE_OB': UnpaidInvoiceNotifyTask.notify_unpaid_invoices() application.logger.info(f'<<<< Completed Sending notification for OB invoices >>>>') + elif job_name == 'STATEMENTS_DUE': + StatementDueTask.process_unpaid_statements() + application.logger.info(f'<<<< Completed Sending notification for unpaid statements >>>>') elif job_name == 'ROUTING_SLIP': RoutingSlipTask.link_routing_slips() RoutingSlipTask.process_void() diff --git a/jobs/payment-jobs/requirements.txt b/jobs/payment-jobs/requirements.txt index e2ae5e5d2..59cf95393 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@f528537124faac5a6809f03449681fc8709e7747#egg=pay_api&subdirectory=pay-api +-e git+https://github.com/bcgov/sbc-pay.git@438faedf454193f8131a83b42795c1814fdcb860#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/run_statement_due_task.sh b/jobs/payment-jobs/run_statement_due_task.sh new file mode 100755 index 000000000..7369ebc67 --- /dev/null +++ b/jobs/payment-jobs/run_statement_due_task.sh @@ -0,0 +1,3 @@ +#! /bin/sh +echo 'run invoke_jobs.py STATEMENTS_DUE' +python3 invoke_jobs.py STATEMENTS_DUE diff --git a/jobs/payment-jobs/tasks/statement_due_task.py b/jobs/payment-jobs/tasks/statement_due_task.py new file mode 100644 index 000000000..7472f7972 --- /dev/null +++ b/jobs/payment-jobs/tasks/statement_due_task.py @@ -0,0 +1,138 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Task to notify user for any outstanding statement.""" +from datetime import timedelta + +from flask import current_app +from pay_api.models import db +from pay_api.models.invoice import Invoice as InvoiceModel +from pay_api.models.payment_account import PaymentAccount as PaymentAccountModel +from pay_api.models.statement import Statement as StatementModel +from pay_api.models.statement_recipients import StatementRecipients as StatementRecipientsModel +from pay_api.models.statement_settings import StatementSettings as StatementSettingsModel +from pay_api.services.flags import flags +from pay_api.services.statement import Statement +from pay_api.utils.enums import InvoiceStatus, PaymentMethod, StatementFrequency +from pay_api.utils.util import current_local_time, get_first_and_last_dates_of_month +from sentry_sdk import capture_message +from sqlalchemy import Date + +from utils.mailer import publish_payment_notification + + +class StatementDueTask: + """Task to notify admin for unpaid statements. + + This is currently for EFT payment method invoices only. This may be expanded to + PAD and ONLINE BANKING in the future. + """ + + @classmethod + def process_unpaid_statements(cls): + """Notify for unpaid statements with an amount owing.""" + eft_enabled = flags.is_on('enable-eft-payment-method', default=False) + + if eft_enabled: + cls._notify_for_monthly() + + # Set overdue status for invoices + if current_local_time().date().day == 1: + cls._update_invoice_overdue_status() + + @classmethod + def _update_invoice_overdue_status(cls): + """Update the status of any invoices that are overdue.""" + unpaid_status = ( + InvoiceStatus.SETTLEMENT_SCHEDULED.value, InvoiceStatus.PARTIAL.value, InvoiceStatus.CREATED.value) + db.session.query(InvoiceModel)\ + .filter(InvoiceModel.payment_method_code == PaymentMethod.EFT.value, + InvoiceModel.overdue_date.isnot(None), + InvoiceModel.overdue_date.cast(Date) <= current_local_time().date(), + InvoiceModel.invoice_status_code.in_(unpaid_status))\ + .update({InvoiceModel.invoice_status_code: InvoiceStatus.OVERDUE.value}, synchronize_session='fetch') + + db.session.commit() + + @classmethod + def _notify_for_monthly(cls): + """Notify for unpaid monthly statements with an amount owing.""" + # Check if we need to send a notification + send_notification, is_due, last_day, previous_month = cls.determine_to_notify_and_is_due() + + if send_notification: + statement_settings = StatementSettingsModel.find_accounts_settings_by_frequency(previous_month, + StatementFrequency.MONTHLY) + auth_account_ids = [pay_account.auth_account_id for _, pay_account in statement_settings] + + for account_id in auth_account_ids: + try: + # Get the most recent monthly statement + statement = cls.find_most_recent_statement(account_id, StatementFrequency.MONTHLY.value) + summary = Statement.get_summary(account_id, statement.id) + payment_account: PaymentAccountModel = PaymentAccountModel.find_by_id(statement.payment_account_id) + + # Send payment notification if payment account is using EFT and there is an amount owing + if payment_account.payment_method == PaymentMethod.EFT.value and summary['total_due'] > 0: + 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: ' + f'{statement.payment_account_id}.Skipping sending') + continue + + to_emails = ','.join([str(recipient.email) for recipient in recipients]) + + publish_payment_notification(pay_account=payment_account, + statement=statement, + is_due=is_due, + due_date=last_day.date(), + emails=to_emails) + except Exception as e: # NOQA # pylint: disable=broad-except + capture_message( + f'Error on unpaid statement notification auth_account_id={account_id}, ' + f'ERROR : {str(e)}', level='error') + current_app.logger.error(e) + continue + + @classmethod + def find_most_recent_statement(cls, auth_account_id: str, statement_frequency: str) -> StatementModel: + """Find all payment and invoices specific to a statement.""" + query = db.session.query(StatementModel) \ + .join(PaymentAccountModel, PaymentAccountModel.auth_account_id == auth_account_id) \ + .filter(StatementModel.frequency == statement_frequency) \ + .order_by(StatementModel.to_date.desc()) + + return query.first() + + @classmethod + def determine_to_notify_and_is_due(cls): + """Determine whether a statement notification is required and due.""" + now = current_local_time() + previous_month = now.replace(day=1) - timedelta(days=1) + send_notification = False + is_due = False + + # Send payment notification if it is 7 days before the due date or on the due date + _, last_day = get_first_and_last_dates_of_month(now.month, now.year) + if last_day.date() == now.date(): + # Last day of the month, send payment due + send_notification = True + is_due = True + elif now.date() == (last_day - timedelta(days=7)).date(): + # 7 days from payment due date, send payment reminder + send_notification = True + is_due = False + + return send_notification, is_due, last_day, previous_month diff --git a/jobs/payment-jobs/tests/jobs/test_statement_due_task.py b/jobs/payment-jobs/tests/jobs/test_statement_due_task.py new file mode 100644 index 000000000..7442c9623 --- /dev/null +++ b/jobs/payment-jobs/tests/jobs/test_statement_due_task.py @@ -0,0 +1,171 @@ +# 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 UnpaidStatementNotifyTask. + +Test-Suite to ensure that the UnpaidStatementNotifyTask is working as expected. +""" +import decimal +from datetime import datetime, timedelta +from dateutil.relativedelta import relativedelta +from unittest.mock import 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, PaymentMethod, StatementFrequency +from pay_api.utils.util import current_local_time, get_first_and_last_dates_of_month, get_previous_month_and_year + +import config +from tasks.statement_task import StatementTask +from tasks.statement_due_task import StatementDueTask + +from .factory import ( + factory_create_account, factory_invoice, factory_invoice_reference, 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.CREATED.value, + total=invoice_total) + inv_ref = factory_invoice_reference(invoice_id=invoice.id) + 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, statement_recipient, statement_settings + + +def test_send_unpaid_statement_notification(setup, session): + """Assert payment reminder event is being sent.""" + last_month, last_year = get_previous_month_and_year() + previous_month_year = datetime(last_year, last_month, 5) + + account, invoice, inv_ref, \ + 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 + + now = current_local_time() + _, last_day = get_first_and_last_dates_of_month(now.month, now.year) + + # Generate statement for previous month - freeze time to the 1st of the current month + with freeze_time(current_local_time().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 + + with app.app_context(): + # Assert notification was published to the mailer queue + with patch('tasks.statement_due_task.publish_payment_notification') as mock_mailer: + # Freeze time to due date - trigger due notification + with freeze_time(last_day): + StatementDueTask.process_unpaid_statements() + mock_mailer.assert_called_with(pay_account=account, + statement=statements[0][0], + is_due=True, + due_date=last_day.date(), + emails=statement_recipient.email) + + # Freeze time to due date - trigger reminder notification + with freeze_time(last_day - timedelta(days=7)): + StatementDueTask.process_unpaid_statements() + mock_mailer.assert_called_with(pay_account=account, + statement=statements[0][0], + is_due=False, + due_date=last_day.date(), + emails=statement_recipient.email) + + +def test_unpaid_statement_notification_not_sent(setup, session): + """Assert payment reminder event is not being sent.""" + with app.app_context(): + # Assert notification was published to the mailer queue + with patch('tasks.statement_due_task.publish_payment_notification') as mock_mailer: + # Freeze time to 10th of the month - should not trigger any notification + with freeze_time(current_local_time().replace(day=10)): + StatementDueTask.process_unpaid_statements() + mock_mailer.assert_not_called() + + +def test_overdue_invoices_updated(setup, session): + """Assert invoices are transitioned to overdue status.""" + invoice_date = current_local_time() + relativedelta(months=-1, day=5) + + # Freeze time to the previous month so the overdue date is set properly and in the past for this test + with freeze_time(invoice_date): + account, invoice, inv_ref, \ + statement_recipient, statement_settings = create_test_data(PaymentMethod.EFT.value, + invoice_date, + StatementFrequency.MONTHLY.value, + 351.50) + + # Freeze time to the current month so the overdue date is in the future for this test + with freeze_time(current_local_time().replace(day=5)): + invoice2 = factory_invoice(payment_account=account, created_on=current_local_time().date(), + payment_method_code=PaymentMethod.EFT.value, status_code=InvoiceStatus.CREATED.value, + total=10.50) + + assert invoice.payment_method_code == PaymentMethod.EFT.value + assert invoice.invoice_status_code == InvoiceStatus.CREATED.value + assert invoice2.payment_method_code == PaymentMethod.EFT.value + assert invoice2.invoice_status_code == InvoiceStatus.CREATED.value + assert account.payment_method == PaymentMethod.EFT.value + + with app.app_context(): + # Freeze time to 1st of the month - should trigger overdue status update for previous month invoices + with freeze_time(current_local_time().replace(day=1)): + StatementDueTask.process_unpaid_statements() + assert invoice.invoice_status_code == InvoiceStatus.OVERDUE.value + assert invoice2.invoice_status_code == InvoiceStatus.CREATED.value diff --git a/jobs/payment-jobs/utils/mailer.py b/jobs/payment-jobs/utils/mailer.py index dfacd9b25..ef9c159cd 100644 --- a/jobs/payment-jobs/utils/mailer.py +++ b/jobs/payment-jobs/utils/mailer.py @@ -93,3 +93,40 @@ def publish_statement_notification(pay_account: PaymentAccountModel, statement: return True + +def publish_payment_notification(pay_account: PaymentAccountModel, statement: StatementModel, + is_due: bool, due_date: datetime, emails: str) -> bool: + """Publish payment notification message to the mailer queue.""" + notification_type = 'bc.registry.payment.statementDueNotification' if is_due \ + else 'bc.registry.payment.statementReminderNotification' + + payload = { + 'specversion': '1.x-wip', + 'type': notification_type, + '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, + 'dueDate': f'{due_date}', + 'statementFrequency': statement.frequency + } + } + 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 +