From c5143558cd1b7ba625f5b8a9e724057e97630f9d Mon Sep 17 00:00:00 2001 From: Odysseus Chiu Date: Mon, 27 Nov 2023 08:40:20 -0800 Subject: [PATCH] 18574 - EFT Verification Fixes --- jobs/payment-jobs/tasks/statement_due_task.py | 128 +++++++++++------- .../tests/jobs/test_statement_due_task.py | 37 +++-- jobs/payment-jobs/utils/mailer.py | 38 ++++-- pay-api/src/pay_api/services/eft_service.py | 5 + 4 files changed, 130 insertions(+), 78 deletions(-) diff --git a/jobs/payment-jobs/tasks/statement_due_task.py b/jobs/payment-jobs/tasks/statement_due_task.py index 7472f7972..ea345fb6d 100644 --- a/jobs/payment-jobs/tasks/statement_due_task.py +++ b/jobs/payment-jobs/tasks/statement_due_task.py @@ -24,11 +24,11 @@ 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 pay_api.utils.util import current_local_time, get_local_time from sentry_sdk import capture_message -from sqlalchemy import Date +from sqlalchemy import func -from utils.mailer import publish_payment_notification +from utils.mailer import StatementNotificationInfo, publish_payment_notification class StatementDueTask: @@ -47,18 +47,20 @@ def process_unpaid_statements(cls): cls._notify_for_monthly() # Set overdue status for invoices - if current_local_time().date().day == 1: - cls._update_invoice_overdue_status() + cls._update_invoice_overdue_status() @classmethod def _update_invoice_overdue_status(cls): """Update the status of any invoices that are overdue.""" + legislative_timezone = current_app.config.get('LEGISLATIVE_TIMEZONE') + overdue_datetime = func.timezone(legislative_timezone, func.timezone('UTC', InvoiceModel.overdue_date)) + unpaid_status = ( InvoiceStatus.SETTLEMENT_SCHEDULED.value, InvoiceStatus.PARTIAL.value, InvoiceStatus.CREATED.value) - db.session.query(InvoiceModel)\ + 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(), + func.date(overdue_datetime) <= current_local_time().date(), InvoiceModel.invoice_status_code.in_(unpaid_status))\ .update({InvoiceModel.invoice_status_code: InvoiceStatus.OVERDUE.value}, synchronize_session='fetch') @@ -67,23 +69,28 @@ def _update_invoice_overdue_status(cls): @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) + previous_month = current_local_time().replace(day=1) - timedelta(days=1) + statement_settings = StatementSettingsModel.find_accounts_settings_by_frequency(previous_month, + StatementFrequency.MONTHLY) + + # Get EFT auth account ids for statements + auth_account_ids = [pay_account.auth_account_id for _, pay_account in statement_settings + if pay_account.payment_method == PaymentMethod.EFT.value] + + current_app.logger.info(f'Processing {len(auth_account_ids)} EFT accounts for monthly reminders.') - # 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: + 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) + invoices: [InvoiceModel] = StatementModel.find_all_payments_and_invoices_for_statement(statement.id) + # check if there is an unpaid statement invoice that requires a reminder + send_notification, is_due, due_date = cls.determine_to_notify_and_is_due(invoices) + + if send_notification: + summary = Statement.get_summary(account_id, statement.id) + # Send payment notification if there is an amount owing + if summary['total_due'] > 0: recipients = StatementRecipientsModel. \ find_all_recipients_for_payment_id(statement.payment_account_id) @@ -94,45 +101,64 @@ def _notify_for_monthly(cls): 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 + publish_payment_notification( + StatementNotificationInfo(auth_account_id=account_id, + statement=statement, + is_due=is_due, + due_date=due_date, + emails=to_emails, + total_amount_owing=summary['total_due'])) + 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) \ + .join(PaymentAccountModel) \ + .filter(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): + def determine_to_notify_and_is_due(cls, invoices: [InvoiceModel]): """Determine whether a statement notification is required and due.""" - now = current_local_time() - previous_month = now.replace(day=1) - timedelta(days=1) + unpaid_status = [InvoiceStatus.SETTLEMENT_SCHEDULED.value, InvoiceStatus.PARTIAL.value, + InvoiceStatus.CREATED.value] + now = current_local_time().date() 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 + due_date = None + + invoice: InvoiceModel + for invoice in invoices: + if invoice.invoice_status_code not in unpaid_status or invoice.overdue_date is None: + continue + + invoice_due_date = get_local_time(invoice.overdue_date) \ + .date() - timedelta(days=1) # Day before invoice overdue date + invoice_reminder_date = invoice_due_date - timedelta(days=7) # 7 days before invoice due date + + # Send payment notification if it is 7 days before the overdue date or on the overdue date + if invoice_due_date == now: + # due today, send payment due + send_notification = True + is_due = True + due_date = invoice_due_date + current_app.logger.info(f'Found invoice due: {invoice.id}.') + break + if invoice_reminder_date == now: + # 7 days till due date, send payment reminder + send_notification = True + is_due = False + due_date = invoice_due_date + current_app.logger.info(f'Found invoice for 7 day reminder: {invoice.id}.') + break + + return send_notification, is_due, due_date diff --git a/jobs/payment-jobs/tests/jobs/test_statement_due_task.py b/jobs/payment-jobs/tests/jobs/test_statement_due_task.py index 7442c9623..60f418f7d 100644 --- a/jobs/payment-jobs/tests/jobs/test_statement_due_task.py +++ b/jobs/payment-jobs/tests/jobs/test_statement_due_task.py @@ -25,13 +25,16 @@ from faker import Faker from flask import Flask from freezegun import freeze_time -from pay_api.models import Statement, StatementInvoices +from pay_api.models import Statement as StatementModel +from pay_api.models import StatementInvoices as StatementInvoicesModel +from pay_api.services 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, get_previous_month_and_year import config from tasks.statement_task import StatementTask from tasks.statement_due_task import StatementDueTask +from utils.mailer import StatementNotificationInfo from .factory import ( factory_create_account, factory_invoice, factory_invoice_reference, factory_statement_recipient, @@ -97,35 +100,41 @@ def test_send_unpaid_statement_notification(setup, session): 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) + statements = StatementModel.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) + invoices = StatementInvoicesModel.find_all_invoices_for_statement(statements[0][0].id) assert invoices is not None assert invoices[0].invoice_id == invoice.id + summary = Statement.get_summary(account.auth_account_id, statements[0][0].id) + total_amount_owing = summary['total_due'] + 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) + mock_mailer.assert_called_with(StatementNotificationInfo(auth_account_id=account.auth_account_id, + statement=statements[0][0], + is_due=True, + due_date=last_day.date(), + emails=statement_recipient.email, + total_amount_owing=total_amount_owing)) # 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) + mock_mailer.assert_called_with(StatementNotificationInfo(auth_account_id=account.auth_account_id, + statement=statements[0][0], + is_due=False, + due_date=last_day.date(), + emails=statement_recipient.email, + total_amount_owing=total_amount_owing)) def test_unpaid_statement_notification_not_sent(setup, session): diff --git a/jobs/payment-jobs/utils/mailer.py b/jobs/payment-jobs/utils/mailer.py index ef9c159cd..439cfd740 100644 --- a/jobs/payment-jobs/utils/mailer.py +++ b/jobs/payment-jobs/utils/mailer.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """Task to activate accounts with pending activation.Mostly for PAD with 3 day activation period.""" - +from dataclasses import dataclass from datetime import datetime from typing import Dict @@ -24,6 +24,18 @@ from sentry_sdk import capture_message +@dataclass +class StatementNotificationInfo: + """Used for Statement Notifications.""" + + auth_account_id: str + statement: StatementModel + is_due: bool + due_date: datetime + emails: str + total_amount_owing: float + + def publish_mailer_events(message_type: str, pay_account: PaymentAccountModel, additional_params: Dict = {}): """Publish payment message to the mailer queue.""" @@ -72,7 +84,7 @@ def publish_statement_notification(pay_account: PaymentAccountModel, statement: 'emailAddresses': emails, 'accountId': pay_account.auth_account_id, 'fromDate': f'{statement.from_date}', - 'toDate:': f'{statement.to_date}', + 'toDate': f'{statement.to_date}', 'statementFrequency': statement.frequency, 'totalAmountOwing': total_amount_owing } @@ -94,24 +106,24 @@ 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: +def publish_payment_notification(info: StatementNotificationInfo) -> bool: """Publish payment notification message to the mailer queue.""" - notification_type = 'bc.registry.payment.statementDueNotification' if is_due \ + notification_type = 'bc.registry.payment.statementDueNotification' if info.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}', + 'source': f'https://api.pay.bcregistry.gov.bc.ca/v1/accounts/{info.auth_account_id}', + 'id': info.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 + 'emailAddresses': info.emails, + 'accountId': info.auth_account_id, + 'dueDate': f'{info.due_date}', + 'statementFrequency': info.statement.frequency, + 'totalAmountOwing': info.total_amount_owing } } try: @@ -121,10 +133,10 @@ def publish_payment_notification(pay_account: PaymentAccountModel, statement: St 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, + info.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') + auth_account_id=info.auth_account_id, msg=payload), level='error') return False diff --git a/pay-api/src/pay_api/services/eft_service.py b/pay-api/src/pay_api/services/eft_service.py index f7f65128c..191af081b 100644 --- a/pay-api/src/pay_api/services/eft_service.py +++ b/pay-api/src/pay_api/services/eft_service.py @@ -60,6 +60,11 @@ def apply_credit(self, invoice: Invoice) -> None: self.create_receipt(invoice=invoice_model, payment=payment).save() self._release_payment(invoice=invoice) + def complete_post_invoice(self, invoice: Invoice, invoice_reference: InvoiceReference) -> None: + """Complete any post invoice activities if needed.""" + # Publish message to the queue with payment token, so that they can release records on their side. + self._release_payment(invoice=invoice) + def create_payment(self, payment_account: PaymentAccountModel, invoice: InvoiceModel, payment_date: datetime, paid_amount) -> PaymentModel: """Create a payment record for an invoice."""