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

18574 - EFT Verification Fixes #1328

Merged
merged 1 commit into from
Nov 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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(),
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix timezone issue

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.')
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added some debug, and added some additional filtering for EFT payment method for auth_account_ids


# 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) \
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed bad join, missing criteria

.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]):
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored to be properly driven by overdue date

"""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}',
Copy link
Collaborator Author

@ochiu ochiu Nov 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed typo in params causing failure on the account mailer

'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:
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Converted params to dataclass to make it cleaner

"""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:
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accidentally reverted this change in a previous PR, adding back in.

This allow payments to be properly released for EFT when invoices are created.

"""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
Loading