Skip to content

Commit

Permalink
Merge branch 'main' of https://github.com/bcgov/sbc-pay into 21519
Browse files Browse the repository at this point in the history
  • Loading branch information
seeker25 committed Sep 13, 2024
2 parents 5f24314 + ff47ad7 commit da18381
Show file tree
Hide file tree
Showing 22 changed files with 268 additions and 93 deletions.
2 changes: 1 addition & 1 deletion jobs/payment-jobs/poetry.lock

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

1 change: 1 addition & 0 deletions jobs/payment-jobs/tasks/common/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ class PaymentDetailsGlStatus(Enum):
INPRG = 'INPRG'
RJCT = 'RJCT' # Should have refundglerrormessage
CMPLT = 'CMPLT'
DECLINED = 'DECLINED'
9 changes: 5 additions & 4 deletions jobs/payment-jobs/tasks/direct_pay_automated_refund_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ def handle_non_complete_credit_card_refunds(cls):
# Cron is setup to run between 6 to 8 UTC. Feedback is updated after 11pm.
current_app.logger.debug(f'Processing invoice: {invoice.id} - created on: {invoice.created_on}')
status = OrderStatus.from_dict(cls._query_order_status(invoice))
if cls._is_glstatus_rejected(status):
if cls._is_glstatus_rejected_or_declined(status):
cls._refund_error(status, invoice)
elif cls._is_status_paid_and_invoice_refund_requested(status, invoice):
cls._refund_paid(invoice)
Expand Down Expand Up @@ -107,7 +107,8 @@ def _query_order_status(cls, invoice: Invoice):
@classmethod
def _refund_error(cls, status: OrderStatus, invoice: Invoice):
"""Log error for rejected GL status."""
current_app.logger.error(f'Refund error - Invoice: {invoice.id} - detected RJCT on refund, contact PAYBC.')
current_app.logger.error(f'Refund error - Invoice: {invoice.id} - detected RJCT/DECLINED on refund,'
"contact PAYBC if it's RJCT.")
errors = ' '.join([refund_data.refundglerrormessage.strip() for revenue_line in status.revenue
for refund_data in revenue_line.refund_data])[:250]
current_app.logger.error(f'Refund error - Invoice: {invoice.id} - glerrormessage: {errors}')
Expand All @@ -134,9 +135,9 @@ def _refund_complete(cls, invoice: Invoice):
refund.save()

@staticmethod
def _is_glstatus_rejected(status: OrderStatus) -> bool:
def _is_glstatus_rejected_or_declined(status: OrderStatus) -> bool:
"""Check for bad refundglstatus."""
return any(refund_data.refundglstatus == PaymentDetailsGlStatus.RJCT
return any(refund_data.refundglstatus in [PaymentDetailsGlStatus.RJCT, PaymentDetailsGlStatus.DECLINED]
for line in status.revenue for refund_data in line.refund_data)

@staticmethod
Expand Down
3 changes: 2 additions & 1 deletion jobs/payment-jobs/tasks/eft_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,8 @@ def _rollback_receipt_and_invoice(cls, cfs_account: CfsAccountModel,
f'not found for invoice id: {invoice.id} - {invoice.invoice_status_code}')
is_invoice_refund = invoice.invoice_status_code == InvoiceStatus.REFUND_REQUESTED.value
is_reversal = not is_invoice_refund
CFSService.reverse_rs_receipt_in_cfs(cfs_account, receipt_number, ReverseOperation.VOID.value)
if receipt_number:
CFSService.reverse_rs_receipt_in_cfs(cfs_account, receipt_number, ReverseOperation.VOID.value)
if is_invoice_refund:
cls._handle_invoice_refund(invoice, invoice_reference)
else:
Expand Down
9 changes: 7 additions & 2 deletions jobs/payment-jobs/tasks/stale_payment_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from pay_api.services import PaymentService, TransactionService
from pay_api.services.direct_pay_service import DirectPayService
from pay_api.utils.enums import InvoiceReferenceStatus, PaymentStatus, TransactionStatus
from requests import HTTPError


STATUS_PAID = ('PAID', 'CMPLT')
Expand Down Expand Up @@ -113,5 +114,9 @@ def _verify_created_direct_pay_invoices(cls):
# check existing payment status in PayBC and save receipt
TransactionService.update_transaction(transaction.id, pay_response_url=None)

except Exception as err: # NOQA # pylint: disable=broad-except
current_app.logger.error(err, exc_info=True)
except HTTPError as http_err:
if http_err.response is None or http_err.response.status_code != 404:
current_app.logger.error(f'HTTPError on verifying invoice {invoice.id}: {http_err}', exc_info=True)
current_app.logger.info(f'Invoice not found (404) at PAYBC. Skipping invoice id: {invoice.id}')
except Exception as err: # NOQA # pylint: disable=broad-except
current_app.logger.error(f'Error verifying invoice {invoice.id}: {err}', exc_info=True)
14 changes: 13 additions & 1 deletion pay-api/src/pay_api/models/eft_credit.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
# limitations under the License.
"""Model to handle all operations related to EFT Credits data."""
from datetime import datetime, timezone
from sqlalchemy import ForeignKey
from decimal import Decimal

from sqlalchemy import ForeignKey, func

from .base_model import BaseModel
from .db import db
Expand Down Expand Up @@ -60,3 +62,13 @@ class EFTCredit(BaseModel): # pylint:disable=too-many-instance-attributes
def find_by_payment_account_id(cls, payment_account_id: int):
"""Find EFT Credit by payment account id."""
return cls.query.filter_by(payment_account_id=payment_account_id).all()

@classmethod
def get_eft_credit_balance(cls, short_name_id: int) -> Decimal:
"""Calculate pay account eft balance by account id."""
result = cls.query.with_entities(func.sum(cls.remaining_amount).label('credit_balance')) \
.filter(cls.short_name_id == short_name_id) \
.group_by(cls.short_name_id) \
.one_or_none()

return Decimal(result.credit_balance) if result else 0
36 changes: 20 additions & 16 deletions pay-api/src/pay_api/models/eft_short_name_links.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# limitations under the License.
"""Model to handle EFT short name to BCROS account mapping links."""
from datetime import datetime, timezone
from typing import List
from typing import List, Self
from _decimal import Decimal

from attrs import define
Expand Down Expand Up @@ -63,25 +63,35 @@ class EFTShortnameLinks(Versioned, BaseModel): # pylint: disable=too-many-insta
updated_by_name = db.Column('updated_by_name', db.String(100), nullable=True)
updated_on = db.Column('updated_on', db.DateTime, nullable=True)

active_statuses = [EFTShortnameStatus.LINKED.value,
EFTShortnameStatus.PENDING.value]

@classmethod
def find_by_short_name_id(cls, short_name_id: int):
def find_by_short_name_id(cls, short_name_id: int) -> Self:
"""Find by eft short name."""
return cls.query.filter_by(eft_short_name_id=short_name_id).all()

@classmethod
def find_active_link(cls, short_name_id: int, auth_account_id: str):
def find_active_link(cls, short_name_id: int, auth_account_id: str) -> Self:
"""Find active link by short name and account."""
return cls.find_link_by_status(short_name_id, auth_account_id,
[EFTShortnameStatus.LINKED.value,
EFTShortnameStatus.PENDING.value])
cls.active_statuses)

@classmethod
def find_inactive_link(cls, short_name_id: int, auth_account_id: str):
def find_active_link_by_auth_id(cls, auth_account_id: str) -> Self:
"""Find active link by auth account id."""
return (cls.query
.filter_by(auth_account_id=auth_account_id)
.filter(cls.status_code.in_(cls.active_statuses))
).one_or_none()

@classmethod
def find_inactive_link(cls, short_name_id: int, auth_account_id: str) -> Self:
"""Find active link by short name and account."""
return cls.find_link_by_status(short_name_id, auth_account_id, [EFTShortnameStatus.INACTIVE.value])

@classmethod
def find_link_by_status(cls, short_name_id: int, auth_account_id: str, statuses: List[str]):
def find_link_by_status(cls, short_name_id: int, auth_account_id: str, statuses: List[str]) -> Self:
"""Find short name account link by status."""
return (cls.query
.filter_by(eft_short_name_id=short_name_id)
Expand All @@ -90,21 +100,15 @@ def find_link_by_status(cls, short_name_id: int, auth_account_id: str, statuses:
).one_or_none()

@classmethod
def get_short_name_links_count(cls, auth_account_id):
def get_short_name_links_count(cls, auth_account_id) -> int:
"""Find short name account link by status."""
statuses = [EFTShortnameStatus.LINKED.value,
EFTShortnameStatus.PENDING.value]
active_link = (cls.query
.filter_by(auth_account_id=auth_account_id)
.filter(cls.status_code.in_(statuses))
).one_or_none()

active_link = cls.find_active_link_by_auth_id(auth_account_id)
if active_link is None:
return 0

return (cls.query
.filter_by(eft_short_name_id=active_link.eft_short_name_id)
.filter(cls.status_code.in_(statuses))).count()
.filter(cls.status_code.in_(cls.active_statuses))).count()


@define
Expand Down
3 changes: 2 additions & 1 deletion pay-api/src/pay_api/resources/v1/account_statements.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ def get_account_statement_summary(account_id: str):
"""Create the statement report."""
current_app.logger.info('<get_account_statement_summary')
check_auth(business_identifier=None, account_id=account_id, contains_role=EDIT_ROLE)
response, status = StatementService.get_summary(account_id), HTTPStatus.OK
response, status = StatementService.get_summary(auth_account_id=account_id,
calculate_under_payment=True), HTTPStatus.OK
current_app.logger.info('>get_account_statement_summary')
return jsonify(response), status
6 changes: 3 additions & 3 deletions pay-api/src/pay_api/services/eft_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ def process_cfs_refund(self, invoice: InvoiceModel,
sibling_cils = [cil for cil in cils if cil.link_group_id == latest_link.link_group_id]
latest_eft_credit = EFTCreditModel.find_by_id(latest_link.eft_credit_id)
link_group_id = EFTCreditInvoiceLinkModel.get_next_group_link_seq()
existing_balance = EFTShortnames.get_eft_credit_balance(latest_eft_credit.short_name_id)
existing_balance = EFTCreditModel.get_eft_credit_balance(latest_eft_credit.short_name_id)

match latest_link.status_code:
case EFTCreditInvoiceStatus.PENDING.value:
Expand Down Expand Up @@ -168,7 +168,7 @@ def process_cfs_refund(self, invoice: InvoiceModel,
target_type=EJVLinkType.INVOICE.value
).flush()

current_balance = EFTShortnames.get_eft_credit_balance(latest_eft_credit.short_name_id)
current_balance = EFTCreditModel.get_eft_credit_balance(latest_eft_credit.short_name_id)
if existing_balance != current_balance:
short_name_history = EFTHistoryModel.find_by_related_group_link_id(latest_link.link_group_id)
EFTHistoryService.create_invoice_refund(
Expand Down Expand Up @@ -235,7 +235,7 @@ def _refund_eft_credits(cls, shortname_id: int, amount: str):
"""Refund the amount to eft_credits table based on short_name_id."""
refund_amount = Decimal(amount)
eft_credits = EFTShortnames.get_eft_credits(shortname_id)
eft_credit_balance = EFTShortnames.get_eft_credit_balance(shortname_id)
eft_credit_balance = EFTCreditModel.get_eft_credit_balance(shortname_id)

if refund_amount > eft_credit_balance:
raise BusinessException(Error.INVALID_REFUND)
Expand Down
20 changes: 7 additions & 13 deletions pay-api/src/pay_api/services/eft_short_names.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,16 +78,6 @@ class EFTShortnamesSearch: # pylint: disable=too-many-instance-attributes
class EFTShortnames: # pylint: disable=too-many-instance-attributes
"""Service to manage EFT short name model operations."""

@staticmethod
def get_eft_credit_balance(short_name_id: int) -> Decimal:
"""Calculate pay account eft balance by account id."""
result = db.session.query(func.sum(EFTCreditModel.remaining_amount).label('credit_balance')) \
.filter(EFTCreditModel.short_name_id == short_name_id) \
.group_by(EFTCreditModel.short_name_id) \
.one_or_none()

return Decimal(result.credit_balance) if result else 0

@staticmethod
def get_eft_credits(short_name_id: int) -> List[EFTCreditModel]:
"""Get EFT Credits with a remaining amount."""
Expand All @@ -110,7 +100,7 @@ def _apply_eft_credit(cls,
# Clear any existing pending credit links on this invoice
cls._cancel_payment_action(short_name_id, payment_account.auth_account_id, invoice_id)

eft_credit_balance = EFTShortnames.get_eft_credit_balance(short_name_id)
eft_credit_balance = EFTCreditModel.get_eft_credit_balance(short_name_id)
invoice_balance = invoice.total - (invoice.paid or 0)

if eft_credit_balance < invoice_balance:
Expand Down Expand Up @@ -336,7 +326,7 @@ def _reverse_payment_action(cls, short_name_id: int, statement_id: int):
EFTHistoryService.create_statement_reverse(
EFTHistory(short_name_id=short_name_id,
amount=reversed_credits,
credit_balance=cls.get_eft_credit_balance(short_name_id),
credit_balance=EFTCreditModel.get_eft_credit_balance(short_name_id),
payment_account_id=statement.payment_account_id,
related_group_link_id=link_group_id,
statement_number=statement_id,
Expand Down Expand Up @@ -495,7 +485,7 @@ def _process_owing_statements(short_name_id: int, auth_account_id: str, is_new_l
if shortname_link is None:
raise BusinessException(Error.EFT_SHORT_NAME_NOT_LINKED)

credit_balance: Decimal = EFTShortnames.get_eft_credit_balance(short_name_id)
credit_balance: Decimal = EFTCreditModel.get_eft_credit_balance(short_name_id)
summary_dict: dict = StatementService.get_summary(auth_account_id)
total_due = summary_dict['total_due']

Expand Down Expand Up @@ -636,6 +626,10 @@ def get_statement_summary_query():
InvoiceModel.id == StatementInvoicesModel.invoice_id,
InvoiceModel.payment_method_code == PaymentMethod.EFT.value
)
).filter(
InvoiceModel.invoice_status_code.notin_([InvoiceStatus.CANCELLED.value,
InvoiceStatus.REFUND_REQUESTED.value,
InvoiceStatus.REFUNDED.value])
).group_by(StatementModel.payment_account_id)

@classmethod
Expand Down
14 changes: 8 additions & 6 deletions pay-api/src/pay_api/services/oauth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ def post(endpoint, token, auth_header_type: AuthHeaderType, # pylint: disable=t
raise ServiceUnavailableException(exc) from exc
except HTTPError as exc:
current_app.logger.error(
f"HTTPError on POST with status code {response.status_code if response else ''}")
if response and response.status_code >= 500:
f"HTTPError on POST with status code {exc.response.status_code if exc.response is not None else ''}")
if exc.response and exc.response.status_code >= 500:
raise ServiceUnavailableException(exc) from exc
raise exc
finally:
Expand Down Expand Up @@ -123,11 +123,13 @@ def get(endpoint, token, auth_header_type: AuthHeaderType, # pylint:disable=too
current_app.logger.error(exc)
raise ServiceUnavailableException(exc) from exc
except HTTPError as exc:
current_app.logger.error(f"HTTPError on GET with status code {response.status_code if response else ''}")
if response is not None:
if response.status_code >= 500:
if exc.response is None or exc.response.status_code != 404:
current_app.logger.error('HTTPError on GET with status code '
f"{exc.response.status_code if exc.response is not None else ''}")
if exc.response is not None:
if exc.response.status_code >= 500:
raise ServiceUnavailableException(exc) from exc
if return_none_if_404 and response.status_code == 404:
if return_none_if_404 and exc.response.status_code == 404:
return None
raise exc
finally:
Expand Down
Loading

0 comments on commit da18381

Please sign in to comment.