Skip to content

Commit

Permalink
17224 - EFT Payment reminder notifications (bcgov#1282)
Browse files Browse the repository at this point in the history
* 17224 - EFT Payment reminder notifications

* PR Fixes

* PR Updates

* PR Updates / Refactor / Clean up
  • Loading branch information
ochiu authored and AbrahamRostampoor committed Oct 26, 2023
1 parent f76c9f2 commit 78a3a33
Show file tree
Hide file tree
Showing 6 changed files with 354 additions and 1 deletion.
4 changes: 4 additions & 0 deletions jobs/payment-jobs/invoke_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion jobs/payment-jobs/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 3 additions & 0 deletions jobs/payment-jobs/run_statement_due_task.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#! /bin/sh
echo 'run invoke_jobs.py STATEMENTS_DUE'
python3 invoke_jobs.py STATEMENTS_DUE
138 changes: 138 additions & 0 deletions jobs/payment-jobs/tasks/statement_due_task.py
Original file line number Diff line number Diff line change
@@ -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
171 changes: 171 additions & 0 deletions jobs/payment-jobs/tests/jobs/test_statement_due_task.py
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions jobs/payment-jobs/utils/mailer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit 78a3a33

Please sign in to comment.