Skip to content

Commit

Permalink
18574 - EFT Verification Fixes
Browse files Browse the repository at this point in the history
  • Loading branch information
ochiu committed Nov 27, 2023
1 parent c671f95 commit c514355
Show file tree
Hide file tree
Showing 4 changed files with 130 additions and 78 deletions.
128 changes: 77 additions & 51 deletions jobs/payment-jobs/tasks/statement_due_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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')

Expand All @@ -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)

Expand All @@ -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
37 changes: 23 additions & 14 deletions jobs/payment-jobs/tests/jobs/test_statement_due_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down
38 changes: 25 additions & 13 deletions jobs/payment-jobs/utils/mailer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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."""
Expand Down Expand Up @@ -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
}
Expand All @@ -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:
Expand All @@ -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

Expand Down
5 changes: 5 additions & 0 deletions pay-api/src/pay_api/services/eft_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down

0 comments on commit c514355

Please sign in to comment.