Skip to content

Commit

Permalink
22391 - EFT Multi Account under payment (#1738)
Browse files Browse the repository at this point in the history
  • Loading branch information
ochiu authored Sep 12, 2024
1 parent 5436bbf commit 96cfc24
Show file tree
Hide file tree
Showing 13 changed files with 226 additions and 63 deletions.
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 @@ -118,7 +118,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 All @@ -141,7 +141,7 @@ def process_cfs_refund(self, invoice: InvoiceModel,
invoice_id=invoice.id,
link_group_id=link_group_id).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 @@ -208,7 +208,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
16 changes: 3 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 @@ -75,16 +75,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 @@ -107,7 +97,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 @@ -327,7 +317,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 @@ -486,7 +476,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
55 changes: 49 additions & 6 deletions pay-api/src/pay_api/services/statement.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@
# limitations under the License.
"""Service class to control all the operations related to statements."""
from datetime import date, datetime, timedelta, timezone
from decimal import Decimal
from typing import List
from dateutil.relativedelta import relativedelta

from dateutil.relativedelta import relativedelta
from flask import current_app
from sqlalchemy import Integer, and_, case, cast, exists, func, literal, literal_column, select
from sqlalchemy import Integer, and_, case, cast, distinct, exists, func, literal, literal_column, select
from sqlalchemy.dialects.postgresql import ARRAY, INTEGER

from pay_api.models import EFTCredit as EFTCreditModel
Expand All @@ -34,8 +35,8 @@
from pay_api.models import db
from pay_api.utils.constants import DT_SHORT_FORMAT
from pay_api.utils.enums import (
ContentType, EFTFileLineType, EFTProcessStatus, InvoiceStatus, NotificationStatus, PaymentMethod,
StatementFrequency, StatementTemplate)
ContentType, EFTFileLineType, EFTProcessStatus, EFTShortnameStatus, InvoiceStatus, NotificationStatus,
PaymentMethod, StatementFrequency, StatementTemplate)
from pay_api.utils.util import get_first_and_last_of_frequency, get_local_time

from .payment import Payment as PaymentService
Expand Down Expand Up @@ -354,7 +355,7 @@ def get_statement_report(statement_id: str, content_type: str, **kwargs):
return report_response, report_name

@staticmethod
def get_summary(auth_account_id: str, statement_id: str = None):
def get_summary(auth_account_id: str, statement_id: str = None, calculate_under_payment: bool = False):
"""Get summary for statements by account id."""
# Used by payment jobs to get the total due amount for statements, keep in mind when modifying.
# This is written outside of the model, because we have multiple model references that need to be included.
Expand Down Expand Up @@ -391,9 +392,51 @@ def get_summary(auth_account_id: str, statement_id: str = None):
'total_invoice_due': float(invoices_unpaid_amount) if invoices_unpaid_amount else 0,
'total_due': total_due,
'oldest_due_date': oldest_due_date,
'short_name_links_count': short_name_links_count
'short_name_links_count': short_name_links_count,
'is_eft_under_payment': Statement._is_eft_under_payment(auth_account_id, calculate_under_payment)
}

@staticmethod
def _get_short_name_owing_balance(short_name_id: int) -> Decimal:
"""Get the total amount owing for a short name statements for all links."""
# Pre-filter payment account ids so there is less data to work with
accounts_query = (db.session.query(PaymentAccountModel.id)
.join(EFTShortnameLinksModel,
PaymentAccountModel.auth_account_id == EFTShortnameLinksModel.auth_account_id)
.filter(EFTShortnameLinksModel.eft_short_name_id == short_name_id,
EFTShortnameLinksModel.status_code.in_([EFTShortnameStatus.LINKED.value,
EFTShortnameStatus.PENDING.value])
))

invoices_subquery = (db.session.query(distinct(InvoiceModel.id),
InvoiceModel.id,
InvoiceModel.total,
InvoiceModel.paid)
.join(StatementInvoicesModel, StatementInvoicesModel.invoice_id == InvoiceModel.id)
.filter(and_(InvoiceModel.payment_method_code == PaymentMethod.EFT.value,
InvoiceModel.invoice_status_code.in_([InvoiceStatus.APPROVED.value,
InvoiceStatus.OVERDUE.value])))
.filter(
InvoiceModel.payment_account_id.in_(accounts_query)).subquery())

query = db.session.query(func.sum(invoices_subquery.c.total - invoices_subquery.c.paid))
owing = query.scalar()
return 0 if owing is None else owing

@staticmethod
def _is_eft_under_payment(auth_account_id: str, calculate_under_payment: bool):
if not calculate_under_payment:
return None
if (active_link := EFTShortnameLinksModel.find_active_link_by_auth_id(auth_account_id)) is None:
return None

short_name_owing = Statement._get_short_name_owing_balance(active_link.eft_short_name_id)
balance = EFTCreditModel.get_eft_credit_balance(active_link.eft_short_name_id)

if 0 < balance < short_name_owing:
return True
return False

@staticmethod
def populate_overdue_from_invoices(statements: List[StatementModel]):
"""Populate is_overdue field for statements."""
Expand Down
14 changes: 7 additions & 7 deletions pay-api/tests/unit/api/test_eft_payment_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ def test_eft_apply_credits_action(db, session, client, jwt, app):
headers=headers)
assert rv.status_code == 204
assert all(eft_credit.remaining_amount == 0 for eft_credit in eft_credits)
assert EFTShortnamesService.get_eft_credit_balance(short_name.id) == 0
assert EFTCreditModel.get_eft_credit_balance(short_name.id) == 0

credit_invoice_links = EFTShortnamesService.get_shortname_invoice_links(short_name.id, account.id,
[EFTCreditInvoiceStatus.PENDING.value
Expand All @@ -140,7 +140,7 @@ def test_eft_apply_credits_action(db, session, client, jwt, app):
assert rv.status_code == 204
assert sum([eft_credit.remaining_amount for eft_credit in eft_credits + eft_credits_2]) == 300
# assert eft_credits_2[0].remaining_amount == 300
assert EFTShortnamesService.get_eft_credit_balance(short_name.id) == 300
assert EFTCreditModel.get_eft_credit_balance(short_name.id) == 300


def test_eft_cancel_payment_action(session, client, jwt, app):
Expand Down Expand Up @@ -170,7 +170,7 @@ def test_eft_cancel_payment_action(session, client, jwt, app):
headers=headers)
assert rv.status_code == 204
assert all(eft_credit.remaining_amount == 0 for eft_credit in eft_credits)
assert EFTShortnamesService.get_eft_credit_balance(short_name.id) == 0
assert EFTCreditModel.get_eft_credit_balance(short_name.id) == 0

credit_offset = 100
eft_credits[1].remaining_amount += credit_offset
Expand All @@ -186,7 +186,7 @@ def test_eft_cancel_payment_action(session, client, jwt, app):
eft_credits[1].save()
# Assert no change and rollback was successful
assert all(eft_credit.remaining_amount == 0 for eft_credit in eft_credits)
assert EFTShortnamesService.get_eft_credit_balance(short_name.id) == 0
assert EFTCreditModel.get_eft_credit_balance(short_name.id) == 0

rv = client.post(f'/api/v1/eft-shortnames/{short_name.id}/payment',
data=json.dumps({'action': EFTPaymentActions.CANCEL.value,
Expand All @@ -196,7 +196,7 @@ def test_eft_cancel_payment_action(session, client, jwt, app):
# Confirm credits have been restored
assert rv.status_code == 204
assert all(eft_credit.remaining_amount == eft_credit.amount for eft_credit in eft_credits)
assert EFTShortnamesService.get_eft_credit_balance(short_name.id) == 200
assert EFTCreditModel.get_eft_credit_balance(short_name.id) == 200


def test_eft_payment_action_schema(db, session, client, jwt, app):
Expand Down Expand Up @@ -246,7 +246,7 @@ def test_eft_reverse_payment_action(db, session, client, jwt, app, admin_users_m
headers=headers)
assert rv.status_code == 204
assert all(eft_credit.remaining_amount == 0 for eft_credit in eft_credits)
assert EFTShortnamesService.get_eft_credit_balance(short_name.id) == 0
assert EFTCreditModel.get_eft_credit_balance(short_name.id) == 0

rv = client.post(f'/api/v1/eft-shortnames/{short_name.id}/payment',
data=json.dumps({'action': EFTPaymentActions.REVERSE.value, 'statementId': statement.id}),
Expand Down Expand Up @@ -322,4 +322,4 @@ def test_eft_reverse_payment_action(db, session, client, jwt, app, admin_users_m
assert credit_invoice_links[1].link_group_id is not None
assert credit_invoice_links[1].amount == credit_invoice_links[0].amount
assert credit_invoice_links[1].receipt_number == credit_invoice_links[0].receipt_number
assert EFTShortnamesService.get_eft_credit_balance(short_name.id) == 100
assert EFTCreditModel.get_eft_credit_balance(short_name.id) == 100
Loading

0 comments on commit 96cfc24

Please sign in to comment.