-
Notifications
You must be signed in to change notification settings - Fork 40
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
17224 - EFT Payment reminder notifications (#1282)
* 17224 - EFT Payment reminder notifications * PR Fixes * PR Updates * PR Updates / Refactor / Clean up
- Loading branch information
Showing
6 changed files
with
354 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
171
jobs/payment-jobs/tests/jobs/test_statement_due_task.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters