Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

20087 - PAY-JOBS / ACCOUNT-MAILER - Overdue BCROS A/C not Paid on Time #1527

Merged
merged 14 commits into from
Jul 15, 2024
2 changes: 2 additions & 0 deletions jobs/payment-jobs/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,13 +193,15 @@ class _Config(object): # pylint: disable=too-few-public-methods
# EFT variables
EFT_HOLDING_GL = os.getenv('EFT_HOLDING_GL', '')
EFT_TRANSFER_DESC = os.getenv('EFT_TRANSFER_DESC', 'BCREGISTRIES {} {} EFT TRANSFER')
EFT_OVERDUE_NOTIFY_EMAILS = os.getenv('EFT_OVERDUE_NOTIFY_EMAILS', '')

# GCP PubSub
AUDIENCE = os.getenv('AUDIENCE', None)
GCP_AUTH_KEY = os.getenv('GCP_AUTH_KEY', None)
PUBLISHER_AUDIENCE = os.getenv('PUBLISHER_AUDIENCE', None)
ACCOUNT_MAILER_TOPIC = os.getenv('ACCOUNT_MAILER_TOPIC', None)


class DevConfig(_Config): # pylint: disable=too-few-public-methods
TESTING = False
DEBUG = True
Expand Down
8 changes: 4 additions & 4 deletions jobs/payment-jobs/poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion jobs/payment-jobs/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ readme = "README.md"

[tool.poetry.dependencies]
python = "^3.12"
pay-api = {git = "https://github.com/seeker25/sbc-pay.git", branch = "22063-part2", subdirectory = "pay-api"}
pay-api = {git = "https://github.com/bcgov/sbc-pay.git", branch = "20087", subdirectory = "pay-api"}
gunicorn = "^21.2.0"
flask = "^3.0.2"
flask-sqlalchemy = "^3.1.1"
Expand Down
144 changes: 66 additions & 78 deletions jobs/payment-jobs/tasks/statement_due_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
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_invoices import StatementInvoices as StatementInvoicesModel
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
Expand All @@ -28,40 +29,40 @@
from sentry_sdk import capture_message
from sqlalchemy import func

from utils.auth_event import AuthEvent
from utils.enums import StatementNotificationAction
from utils.mailer import StatementNotificationInfo, publish_payment_notification


class StatementDueTask:
class StatementDueTask: # pylint: disable=too-few-public-methods
"""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.
"""

unpaid_status = [InvoiceStatus.SETTLEMENT_SCHEDULED.value, InvoiceStatus.PARTIAL.value,
InvoiceStatus.CREATED.value]

@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
cls._update_invoice_overdue_status()
cls._notify_for_monthly()

@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) \
.filter(InvoiceModel.payment_method_code == PaymentMethod.EFT.value,
InvoiceModel.overdue_date.isnot(None),
func.date(overdue_datetime) <= current_local_time().date(),
InvoiceModel.invoice_status_code.in_(unpaid_status))\
InvoiceModel.invoice_status_code.in_(cls.unpaid_status))\
.update({InvoiceModel.invoice_status_code: InvoiceStatus.OVERDUE.value}, synchronize_session='fetch')

db.session.commit()
Expand All @@ -72,51 +73,37 @@ def _notify_for_monthly(cls):
previous_month = current_local_time().replace(day=1) - timedelta(days=1)
statement_settings = StatementSettingsModel.find_accounts_settings_by_frequency(previous_month,
StatementFrequency.MONTHLY)
eft_payment_accounts = [pay_account for _, pay_account in statement_settings
if pay_account.payment_method == PaymentMethod.EFT.value]

# 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.')

for account_id in auth_account_ids:
current_app.logger.info(f'Processing {len(eft_payment_accounts)} EFT accounts for monthly reminders.')
for payment_account in eft_payment_accounts:
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)

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(
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']))
statement = cls._find_most_recent_statement(
payment_account.auth_account_id, StatementFrequency.MONTHLY.value)
action, due_date = cls._determine_action_and_due_date_by_invoice(statement.id)
total_due = Statement.get_summary(payment_account.auth_account_id, statement.id)['total_due']
if action and total_due > 0 and (emails := cls._determine_recipient_emails(statement, action)):
if action == StatementNotificationAction.OVERDUE:
current_app.logger.info('Freezing payment account id: %s and locking auth account id: %s',
payment_account.id, payment_account.auth_account_id)
AuthEvent.publish_lock_account_event(payment_account)
publish_payment_notification(
StatementNotificationInfo(auth_account_id=payment_account.auth_account_id,
statement=statement,
action=action,
due_date=due_date,
emails=emails,
total_amount_owing=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 on unpaid statement notification auth_account_id={payment_account.auth_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:
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) \
Expand All @@ -127,38 +114,39 @@ def find_most_recent_statement(cls, auth_account_id: str, statement_frequency: s
return query.first()

@classmethod
def determine_to_notify_and_is_due(cls, invoices: [InvoiceModel]):
"""Determine whether a statement notification is required and due."""
unpaid_status = [InvoiceStatus.SETTLEMENT_SCHEDULED.value, InvoiceStatus.PARTIAL.value,
InvoiceStatus.CREATED.value]
now = current_local_time().date()
send_notification = False
is_due = False
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
def _determine_action_and_due_date_by_invoice(cls, statement_id: int):
"""Find the most overdue invoice for a statement and provide an action."""
invoice = db.session.query(InvoiceModel) \
.join(StatementInvoicesModel, StatementInvoicesModel.invoice_id == InvoiceModel.id) \
.filter(StatementInvoicesModel.statement_id == statement_id) \
.filter(InvoiceModel.overdue_date.isnot(None)) \
.order_by(InvoiceModel.overdue_date.asc()) \
.first()

if invoice is None:
return None, None

day_before_invoice_overdue = get_local_time(invoice.overdue_date).date() - timedelta(days=1)
seven_days_before_invoice_due = day_before_invoice_overdue - timedelta(days=7)
now_date = current_local_time().date()

if invoice.invoice_status_code == InvoiceStatus.OVERDUE.value:
return StatementNotificationAction.OVERDUE, day_before_invoice_overdue
if day_before_invoice_overdue == now_date:
return StatementNotificationAction.DUE, day_before_invoice_overdue
if seven_days_before_invoice_due == now_date:
return StatementNotificationAction.REMINDER, day_before_invoice_overdue
return None, day_before_invoice_overdue

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
@classmethod
def _determine_recipient_emails(cls,
statement: StatementRecipientsModel, action: StatementNotificationAction) -> str:
if (recipients := StatementRecipientsModel.find_all_recipients_for_payment_id(statement.payment_account_id)):
recipients = ','.join([str(recipient.email) for recipient in recipients])
if action == StatementNotificationAction.OVERDUE:
if overdue_notify_emails := current_app.config.get('EFT_OVERDUE_NOTIFY_EMAILS'):
recipients += ',' + overdue_notify_emails
return recipients

current_app.logger.info(f'No recipients found for statement: {statement.payment_account_id}. Skipping sending.')
return None
2 changes: 1 addition & 1 deletion jobs/payment-jobs/tests/jobs/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ def factory_create_pad_account(auth_account_id='1234', bank_number='001', bank_b
name=f'Test {auth_account_id}').save()
CfsAccount(status=status, account_id=account.id, bank_number=bank_number,
bank_branch_number=bank_branch, bank_account_number=bank_account,
payment_method=payment_method).save()
payment_method=PaymentMethod.PAD.value).save()
return account


Expand Down
18 changes: 11 additions & 7 deletions jobs/payment-jobs/tests/jobs/test_statement_due_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import config
from tasks.statement_task import StatementTask
from tasks.statement_due_task import StatementDueTask
from utils.enums import StatementNotificationAction
from utils.mailer import StatementNotificationInfo

from .factory import (
Expand Down Expand Up @@ -88,7 +89,6 @@ def test_send_unpaid_statement_notification(setup, session):
previous_month_year,
StatementFrequency.MONTHLY.value,
351.50)

assert invoice.payment_method_code == PaymentMethod.EFT.value
assert account.payment_method == PaymentMethod.EFT.value

Expand All @@ -99,7 +99,6 @@ def test_send_unpaid_statement_notification(setup, session):
with freeze_time(current_local_time().replace(day=1, hour=1)):
StatementTask.generate_statements()

# Assert statements and invoice was created
statements = StatementService.get_account_statements(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
Expand All @@ -111,24 +110,29 @@ def test_send_unpaid_statement_notification(setup, session):
summary = StatementService.get_summary(account.auth_account_id, statements[0][0].id)
total_amount_owing = summary['total_due']

# 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(StatementNotificationInfo(auth_account_id=account.auth_account_id,
statement=statements[0][0],
is_due=True,
action=StatementNotificationAction.DUE,
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(StatementNotificationInfo(auth_account_id=account.auth_account_id,
statement=statements[0][0],
is_due=False,
action=StatementNotificationAction.REMINDER,
due_date=last_day.date(),
emails=statement_recipient.email,
total_amount_owing=total_amount_owing))
with freeze_time(last_day + timedelta(days=7)):
StatementDueTask.process_unpaid_statements()
mock_mailer.assert_called_with(StatementNotificationInfo(auth_account_id=account.auth_account_id,
statement=statements[0][0],
action=StatementNotificationAction.OVERDUE,
due_date=last_day.date(),
emails=statement_recipient.email,
total_amount_owing=total_amount_owing))
Expand Down
40 changes: 40 additions & 0 deletions jobs/payment-jobs/utils/auth_event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Common code that sends AUTH events."""
from flask import current_app
from pay_api.models import PaymentAccount as PaymentAccountModel
from pay_api.services import gcp_queue_publisher
from pay_api.services.gcp_queue_publisher import QueueMessage
from pay_api.utils.enums import PaymentMethod, QueueSources, SuspensionReasonCodes
from sbc_common_components.utils.enums import QueueMessageTypes
from sentry_sdk import capture_message


class AuthEvent:
"""Publishes to the auth-queue as an auth event though PUBSUB, this message gets sent to account-mailer after."""

@staticmethod
def publish_lock_account_event(pay_account: PaymentAccountModel):
"""Publish payment message to the mailer queue."""
try:
payload = AuthEvent._create_event_payload(pay_account)
gcp_queue_publisher.publish_to_queue(
QueueMessage(
source=QueueSources.PAY_JOBS.value,
message_type=QueueMessageTypes.NSF_LOCK_ACCOUNT.value,
payload=payload,
topic=current_app.config.get('AUTH_QUEUE_TOPIC')
)
)
except Exception: # NOQA pylint: disable=broad-except
current_app.logger.error('Error publishing lock event:', exc_info=True)
current_app.logger.warning(f'Notification to Queue failed for the Account {
pay_account.auth_account_id} - {pay_account.name}')
capture_message(f'Notification to Queue failed for the Account {
pay_account.auth_account_id}, {payload}.', level='error')

@staticmethod
def _create_event_payload(pay_account):
return {
'accountId': pay_account.auth_account_id,
'paymentMethod': PaymentMethod.EFT.value,
'suspensionReasonCode': SuspensionReasonCodes.OVERDUE_EFT.value
}
9 changes: 9 additions & 0 deletions jobs/payment-jobs/utils/enums.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from enum import Enum


class StatementNotificationAction(Enum):
"""Enum for the action to take for a statement."""

DUE = 'due'
OVERDUE = 'overdue'
REMINDER = 'reminder'
Loading
Loading