Skip to content

Commit

Permalink
Merge branch 'main' of https://github.com/bcgov/sbc-pay into 19182
Browse files Browse the repository at this point in the history
  • Loading branch information
seeker25 committed Oct 25, 2024
2 parents 26a61da + ed6363b commit 2782ff9
Show file tree
Hide file tree
Showing 47 changed files with 3,964 additions and 3,298 deletions.
2 changes: 1 addition & 1 deletion bcol-api/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/ftp-poller/poetry.lock

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

28 changes: 0 additions & 28 deletions jobs/payment-jobs/services/email_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,43 +14,15 @@

"""This manages all of the email notification service."""
import os
from decimal import Decimal
from typing import Dict

from attr import define
from flask import current_app
from jinja2 import Environment, FileSystemLoader
from pay_api.services.auth import get_service_account_token
from pay_api.services.oauth_service import OAuthService
from pay_api.utils.enums import AuthHeaderType, ContentType


def send_email(recipients: str, subject: str, body: str):
"""Send the email notification."""
# Note if we send HTML in the body, we aren't sending through GCNotify, ideally we'd like to send through GCNotify.
token = get_service_account_token()
current_app.logger.info(f">send_email to recipients: {recipients}")
notify_url = current_app.config.get("NOTIFY_API_ENDPOINT") + "notify/"
notify_body = {
"recipients": recipients,
"content": {"subject": subject, "body": body},
}

try:
notify_response = OAuthService.post(
notify_url,
token=token,
auth_header_type=AuthHeaderType.BEARER,
content_type=ContentType.JSON,
data=notify_body,
)
current_app.logger.info("<send_email notify_response")
if notify_response:
current_app.logger.info(f"Successfully sent email to {recipients}")
except Exception as e: # NOQA pylint:disable=broad-except
current_app.logger.error(f"Error sending email to {recipients}: {e}")


def _get_template(template_file_name: str):
"""Retrieve template."""
current_dir = os.path.dirname(os.path.abspath(__file__))
Expand Down
58 changes: 56 additions & 2 deletions jobs/payment-jobs/tasks/direct_pay_automated_refund_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from pay_api.models import Invoice as InvoiceModel
from pay_api.models import Payment as PaymentModel
from pay_api.models import Refund as RefundModel
from pay_api.models import RefundsPartial as RefundsPartialModel
from pay_api.models.invoice import Invoice
from pay_api.services.direct_pay_service import DirectPayService
from pay_api.services.oauth_service import OAuthService
Expand Down Expand Up @@ -50,6 +51,39 @@ class DirectPayAutomatedRefundTask: # pylint:disable=too-few-public-methods
def process_cc_refunds(cls):
"""Check Credit Card refunds through CAS service."""
cls.handle_non_complete_credit_card_refunds()
cls.handle_credit_card_refund_partials()

@classmethod
def handle_credit_card_refund_partials(cls):
"""Process credit card partial refunds."""
invoices: List[InvoiceModel] = (
InvoiceModel.query.join(RefundsPartialModel, RefundsPartialModel.invoice_id == Invoice.id)
.filter(InvoiceModel.payment_method_code == PaymentMethod.DIRECT_PAY.value)
.filter(InvoiceModel.invoice_status_code == InvoiceStatus.PAID.value)
.filter(RefundsPartialModel.gl_posted.is_(None))
.order_by(InvoiceModel.id, RefundsPartialModel.id)
.distinct(InvoiceModel.id)
.all()
)

current_app.logger.info(f"Found {len(invoices)} invoices to process for partial refunds.")
for invoice in invoices:
try:
current_app.logger.debug(f"Processing invoice: {invoice.id} - refund_date: {invoice.refund_date}")
status = OrderStatus.from_dict(cls._query_order_status(invoice))
if cls._is_glstatus_rejected_or_declined(status):
cls._refund_error(status=status, invoice=invoice)
elif cls._is_status_complete(status):
cls._refund_complete(invoice=invoice, is_partial_refund=True)
else:
current_app.logger.info("No action taken for invoice partial refund.")
except Exception as e: # NOQA # pylint: disable=broad-except disable=invalid-name
capture_message(
f"Error on processing credit card partial refund - invoice: {invoice.id}"
f"status={invoice.invoice_status_code} ERROR : {str(e)}",
level="error",
)
current_app.logger.error(e, exc_info=True)

@classmethod
def handle_non_complete_credit_card_refunds(cls):
Expand Down Expand Up @@ -146,10 +180,13 @@ def _refund_paid(cls, invoice: Invoice):
cls._set_invoice_and_payment_to_refunded(invoice)

@classmethod
def _refund_complete(cls, invoice: Invoice):
def _refund_complete(cls, invoice: Invoice, is_partial_refund: bool = False):
"""Refund was successfully posted to a GL. Set gl_posted to now (filtered out)."""
# Set these to refunded, incase we skipped the PAID state and went to CMPLT
cls._set_invoice_and_payment_to_refunded(invoice)
if not is_partial_refund:
cls._set_invoice_and_payment_to_refunded(invoice)
else:
cls._set_refund_partials_posted(invoice)
current_app.logger.info("Refund complete - GL was posted - setting refund.gl_posted to now.")
refund = RefundModel.find_by_invoice_id(invoice.id)
refund.gl_posted = datetime.now(tz=timezone.utc)
Expand Down Expand Up @@ -196,3 +233,20 @@ def _set_invoice_and_payment_to_refunded(invoice: Invoice):
payment = PaymentModel.find_payment_for_invoice(invoice.id)
payment.payment_status_code = PaymentStatus.REFUNDED.value
payment.save()

@classmethod
def _set_refund_partials_posted(cls, invoice: Invoice):
"""Set Refund partials gl_posted."""
refund_partials = cls._find_refund_partials_by_invoice_id(invoice.id)
for refund_partial in refund_partials:
refund_partial.gl_posted = datetime.now(tz=timezone.utc)
refund_partial.save()

@staticmethod
def _find_refund_partials_by_invoice_id(invoice_id: int) -> List[RefundsPartialModel]:
"""Retrieve Refunds partials by invoice id to be processed."""
return (
RefundsPartialModel.query.filter(RefundsPartialModel.invoice_id == invoice_id)
.filter(RefundModel.gl_posted.is_(None) & RefundModel.gl_error.is_(None))
.all()
)
3 changes: 2 additions & 1 deletion jobs/payment-jobs/tasks/eft_overpayment_notification_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@
from pay_api.models.eft_short_name_links import EFTShortnameLinks as EFTShortnameLinkModel
from pay_api.models.eft_short_names import EFTShortnames as EFTShortnameModel
from pay_api.services.auth import get_emails_with_keycloak_role
from pay_api.services.email_service import send_email
from pay_api.utils.enums import EFTShortnameStatus, Role
from sentry_sdk import capture_message
from sqlalchemy import and_, func

from services.email_service import _render_eft_overpayment_template, send_email
from services.email_service import _render_eft_overpayment_template


class EFTOverpaymentNotificationTask: # pylint: disable=too-few-public-methods
Expand Down
8 changes: 8 additions & 0 deletions jobs/payment-jobs/tasks/ejv_partner_distribution_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,14 @@ def _update_disbursement_status_and_ejv_link(
else:
raise NotImplementedError("Unknown disbursement type")

# Possible this could already be created, eg two PLI.
if db.session.query(EjvLinkModel).filter(
EjvLinkModel.link_id == disbursement.line_item.identifier,
EjvLinkModel.link_type == disbursement.line_item.target_type,
EjvLinkModel.ejv_header_id == ejv_header_model.id,
).first():
return

db.session.add(
EjvLinkModel(
link_id=disbursement.line_item.identifier,
Expand Down
2 changes: 2 additions & 0 deletions jobs/payment-jobs/tests/jobs/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -583,13 +583,15 @@ def factory_refund_invoice(invoice_id: int, details={}):

def factory_refund_partial(
payment_line_item_id: int,
invoice_id: int,
refund_amount: float,
refund_type: str,
created_by="test",
created_on: datetime = datetime.now(tz=timezone.utc),
):
"""Return Factory."""
return RefundsPartial(
invoice_id=invoice_id,
payment_line_item_id=payment_line_item_id,
refund_amount=refund_amount,
refund_type=refund_type,
Expand Down
106 changes: 105 additions & 1 deletion jobs/payment-jobs/tests/jobs/test_direct_pay_automated_refund_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,24 @@
"""Tests for direct pay automated refund task."""
import datetime

import pytest
from freezegun import freeze_time
from pay_api.models import FeeSchedule as FeeScheduleModel
from pay_api.models import Refund as RefundModel
from pay_api.utils.enums import InvoiceReferenceStatus, InvoiceStatus, PaymentStatus
from pay_api.models import RefundsPartial as RefundsPartialModel
from pay_api.utils.enums import InvoiceReferenceStatus, InvoiceStatus, PaymentStatus, RefundsPartialType

from tasks.common.enums import PaymentDetailsGlStatus
from tasks.direct_pay_automated_refund_task import DirectPayAutomatedRefundTask

from .factory import (
factory_create_direct_pay_account,
factory_invoice,
factory_invoice_reference,
factory_payment,
factory_payment_line_item,
factory_refund_invoice,
factory_refund_partial,
)


Expand Down Expand Up @@ -127,3 +133,101 @@ def payment_status(
DirectPayAutomatedRefundTask().process_cc_refunds()
assert refund.gl_error == "BAD BAD"
assert refund.gl_posted is None


def test_complete_refund_partial(session, monkeypatch):
"""Test partial refund GL complete."""
invoice = factory_invoice(factory_create_direct_pay_account(), status_code=InvoiceStatus.PAID.value)
invoice.refund_date = datetime.datetime.now(tz=datetime.timezone.utc)
invoice.save()
factory_invoice_reference(invoice.id, invoice.id, InvoiceReferenceStatus.COMPLETED.value).save()
payment = factory_payment("PAYBC", invoice_number=invoice.id, payment_status_code=PaymentStatus.COMPLETED.value)
factory_refund_invoice(invoice.id)

fee_schedule = FeeScheduleModel.find_by_filing_type_and_corp_type("CP", "OTANN")
line = factory_payment_line_item(invoice.id, fee_schedule_id=fee_schedule.fee_schedule_id)
line.save()

factory_refund_partial(
invoice_id=invoice.id,
payment_line_item_id=line.id,
refund_amount=line.filing_fees - 1,
created_by="test",
refund_type=RefundsPartialType.BASE_FEES.value,
)

def payment_status(
cls,
): # pylint: disable=unused-argument; mocks of library methods
return {"revenue": [{"refund_data": [{"refundglstatus": "CMPLT", "refundglerrormessage": ""}]}]}

target = "tasks.direct_pay_automated_refund_task.DirectPayAutomatedRefundTask._query_order_status"
monkeypatch.setattr(target, payment_status)

with freeze_time(
datetime.datetime.combine(datetime.datetime.now(tz=datetime.timezone.utc).date(), datetime.time(6, 00))
):
DirectPayAutomatedRefundTask().process_cc_refunds()
refund = RefundModel.find_by_invoice_id(invoice.id)
assert invoice.invoice_status_code == InvoiceStatus.PAID.value
assert invoice.refund_date is not None
assert payment.payment_status_code == PaymentStatus.COMPLETED.value
assert refund.gl_posted is not None

refunds_partials = session.query(RefundsPartialModel).filter(RefundsPartialModel.invoice_id == invoice.id).all()
assert refunds_partials
assert refunds_partials[0].gl_posted is not None


@pytest.mark.parametrize(
"gl_error_code, gl_error_message",
[
(PaymentDetailsGlStatus.RJCT.value, "REJECTED"),
(PaymentDetailsGlStatus.DECLINED.value, "DECLINED"),
],
)
def test_error_refund_partial(session, monkeypatch, gl_error_code, gl_error_message):
"""Test partial refund GL error."""
invoice = factory_invoice(factory_create_direct_pay_account(), status_code=InvoiceStatus.PAID.value)
invoice.refund_date = datetime.datetime.now(tz=datetime.timezone.utc)
invoice.save()
factory_invoice_reference(invoice.id, invoice.id, InvoiceReferenceStatus.COMPLETED.value).save()
payment = factory_payment("PAYBC", invoice_number=invoice.id, payment_status_code=PaymentStatus.COMPLETED.value)
factory_refund_invoice(invoice.id)

fee_schedule = FeeScheduleModel.find_by_filing_type_and_corp_type("CP", "OTANN")
line = factory_payment_line_item(invoice.id, fee_schedule_id=fee_schedule.fee_schedule_id)
line.save()

factory_refund_partial(
invoice_id=invoice.id,
payment_line_item_id=line.id,
refund_amount=line.filing_fees - 1,
created_by="test",
refund_type=RefundsPartialType.BASE_FEES.value,
)

def payment_status(
cls,
): # pylint: disable=unused-argument; mocks of library methods
return {
"revenue": [{"refund_data": [{"refundglstatus": gl_error_code, "refundglerrormessage": gl_error_message}]}]
}

target = "tasks.direct_pay_automated_refund_task.DirectPayAutomatedRefundTask._query_order_status"
monkeypatch.setattr(target, payment_status)

with freeze_time(
datetime.datetime.combine(datetime.datetime.now(tz=datetime.timezone.utc).date(), datetime.time(6, 00))
):
DirectPayAutomatedRefundTask().process_cc_refunds()
refund = RefundModel.find_by_invoice_id(invoice.id)
assert invoice.invoice_status_code == InvoiceStatus.PAID.value
assert invoice.refund_date is not None
assert payment.payment_status_code == PaymentStatus.COMPLETED.value
assert refund.gl_posted is None
assert refund.gl_error == gl_error_message

refunds_partials = session.query(RefundsPartialModel).filter(RefundsPartialModel.invoice_id == invoice.id).all()
assert refunds_partials
assert refunds_partials[0].gl_posted is None
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Refund Partial updates for automated refund job.
Revision ID: 59db97e9432e
Revises: ed487561aeeb
Create Date: 2024-10-21 10:18:58.828246
"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
# Note you may see foreign keys with distribution_codes_history
# For disbursement_distribution_code_id, service_fee_distribution_code_id
# Please ignore those lines and don't include in migration.

revision = '59db97e9432e'
down_revision = 'ed487561aeeb'
branch_labels = None
depends_on = None


def upgrade():
with op.batch_alter_table('refunds_partial', schema=None) as batch_op:
batch_op.add_column(sa.Column('gl_posted', sa.DateTime(), nullable=True))
batch_op.add_column(sa.Column('invoice_id', sa.Integer(), nullable=True))
batch_op.create_foreign_key('refunds_partial_invoice_id_fkey', 'invoices', ['invoice_id'], ['id'])

with op.batch_alter_table('refunds_partial_history', schema=None) as batch_op:
batch_op.add_column(sa.Column('gl_posted', sa.DateTime(), autoincrement=False, nullable=True))
batch_op.add_column(sa.Column('invoice_id', sa.Integer(), autoincrement=False, nullable=True))
batch_op.create_foreign_key('refunds_partial_history_invoice_id_fkey', 'invoices', ['invoice_id'], ['id'])

op.execute("""
UPDATE refunds_partial
SET invoice_id = (
SELECT payment_line_items.invoice_id
FROM payment_line_items
WHERE payment_line_items.id = refunds_partial.payment_line_item_id
)
""")

op.execute("""
UPDATE refunds_partial_history
SET invoice_id = (
SELECT payment_line_items.invoice_id
FROM payment_line_items
WHERE payment_line_items.id = refunds_partial_history.payment_line_item_id
)
""")

with op.batch_alter_table('refunds_partial', schema=None) as batch_op:
batch_op.alter_column('invoice_id', nullable=False)

with op.batch_alter_table('refunds_partial_history', schema=None) as batch_op:
batch_op.alter_column('invoice_id', nullable=False)


def downgrade():
with op.batch_alter_table('refunds_partial_history', schema=None) as batch_op:
batch_op.drop_constraint('refunds_partial_history_invoice_id_fkey', type_='foreignkey')
batch_op.drop_column('invoice_id')
batch_op.drop_column('gl_posted')

with op.batch_alter_table('refunds_partial', schema=None) as batch_op:
batch_op.drop_constraint('refunds_partial_invoice_id_fkey', type_='foreignkey')
batch_op.drop_column('invoice_id')
batch_op.drop_column('gl_posted')
Loading

0 comments on commit 2782ff9

Please sign in to comment.