Skip to content

Commit

Permalink
20907 - Pay outstanding balance switching from EFT payment method
Browse files Browse the repository at this point in the history
  • Loading branch information
ochiu committed Jun 18, 2024
1 parent c6084bf commit 137648c
Show file tree
Hide file tree
Showing 12 changed files with 207 additions and 40 deletions.
8 changes: 8 additions & 0 deletions pay-api/src/pay_api/models/invoice.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,14 @@ def find_by_business_identifier(cls, business_identifier: str):
"""Find all payment accounts by business_identifier."""
return cls.query.filter_by(business_identifier=business_identifier).all()

@classmethod
def find_invoices_by_status_for_account(cls, pay_account_id: int, invoice_statuses: List[str]):
"""Return invoices by status for an account."""
query = cls.query.filter_by(payment_account_id=pay_account_id). \
filter(Invoice.invoice_status_code.in_(invoice_statuses))

return query.all()

@classmethod
def find_invoices_for_payment(cls,
payment_id: int,
Expand Down
43 changes: 40 additions & 3 deletions pay-api/src/pay_api/models/statement.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@
"""Model to handle statements data."""

from datetime import datetime

import pytz
from marshmallow import fields
from sql_versioning import history_cls
from sqlalchemy import ForeignKey, Integer, and_, case, cast, literal_column
from sqlalchemy import ForeignKey, Integer, and_, case, cast, func, literal_column
from sqlalchemy.ext.hybrid import hybrid_property

from pay_api.utils.constants import LEGISLATIVE_TIMEZONE
from pay_api.utils.enums import StatementFrequency
from pay_api.utils.enums import InvoiceStatus, StatementFrequency

from .base_model import BaseModel
from .db import db, ma
Expand Down Expand Up @@ -115,14 +116,49 @@ def payment_methods(self):

return list(payment_methods)

@hybrid_property
def amount_owing(self):
"""Return amount owing on statement."""
from .statement_invoices import StatementInvoices # pylint: disable=import-outside-toplevel
query = self.get_statement_owing_query()
query = (query.filter(StatementInvoices.statement_id == self.id)
.with_entities(func.sum(Invoice.total - Invoice.paid).label('total_owing')))

result = query.scalar()
return result if result is not None else 0

@staticmethod
def get_statement_owing_query():
"""Get statement query used for amount owing."""
from .statement_invoices import StatementInvoices # pylint: disable=import-outside-toplevel
return (
db.session.query(
StatementInvoices.statement_id,
func.sum(Invoice.total - Invoice.paid).label('total_owing'))
.join(StatementInvoices, StatementInvoices.invoice_id == Invoice.id)
.filter(Invoice.invoice_status_code.in_([
InvoiceStatus.PARTIAL.value,
InvoiceStatus.CREATED.value,
InvoiceStatus.OVERDUE.value])
)
.group_by(StatementInvoices.statement_id)
)

@classmethod
def find_all_statements_for_account(cls, auth_account_id: str, page, limit):
def find_all_statements_for_account(cls, auth_account_id: str, page, limit, is_owing: bool = None):
"""Return all active statements for an account."""
query = cls.query \
.join(PaymentAccount) \
.filter(and_(PaymentAccount.id == cls.payment_account_id,
PaymentAccount.auth_account_id == auth_account_id))

if is_owing:
owing_subquery = cls.get_statement_owing_query().subquery()
query = query.join(
owing_subquery,
owing_subquery.c.statement_id == cls.id
).filter(owing_subquery.c.total_owing > 0)

frequency_case = case(
(
Statement.frequency == StatementFrequency.MONTHLY.value,
Expand Down Expand Up @@ -175,3 +211,4 @@ class Meta: # pylint: disable=too-few-public-methods
to_date = fields.Date(tzinfo=pytz.timezone(LEGISLATIVE_TIMEZONE))
is_overdue = fields.Boolean()
payment_methods = fields.List(fields.String())
amount_owing = fields.Float(missing=0)
2 changes: 2 additions & 0 deletions pay-api/src/pay_api/resources/v1/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ def put_account(account_number: str):
response = PaymentAccountService.update(account_number, request_json)
except ServiceUnavailableException as exception:
return exception.response()
except BusinessException as exception:
return exception.response()

status = HTTPStatus.ACCEPTED \
if response.cfs_account_id and response.cfs_account_status == CfsAccountStatus.PENDING.value \
Expand Down
6 changes: 3 additions & 3 deletions pay-api/src/pay_api/resources/v1/account_statements.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from pay_api.utils.constants import EDIT_ROLE
from pay_api.utils.endpoints_enums import EndpointEnum
from pay_api.utils.enums import ContentType

from pay_api.utils.util import string_to_bool

bp = Blueprint('ACCOUNT_STATEMENTS', __name__,
url_prefix=f'{EndpointEnum.API_V1.value}/accounts/<string:account_id>/statements')
Expand All @@ -36,14 +36,14 @@
def get_account_statements(account_id):
"""Get all statements records for an account."""
current_app.logger.info('<get_account_statements')

# Check if user is authorized to perform this action
check_auth(business_identifier=None, account_id=account_id, contains_role=EDIT_ROLE)

page: int = int(request.args.get('page', '1'))
limit: int = int(request.args.get('limit', '10'))
is_owing = string_to_bool(request.args.get('isOwing', None))

response, status = StatementService.find_by_account_id(account_id, page, limit), HTTPStatus.OK
response, status = StatementService.find_by_account_id(account_id, page, limit, is_owing), HTTPStatus.OK
current_app.logger.debug('>get_account_statements')
return jsonify(response), status

Expand Down
9 changes: 7 additions & 2 deletions pay-api/src/pay_api/resources/v1/payment.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from flask import Blueprint, current_app, g, jsonify, request
from flask_cors import cross_origin
from pay_api.services.flags import flags

from pay_api.exceptions import error_to_response
from pay_api.schemas import utils as schema_utils
Expand All @@ -27,7 +28,6 @@
from pay_api.utils.enums import PaymentMethod, Role
from pay_api.utils.errors import Error


bp = Blueprint('PAYMENTS', __name__, url_prefix=f'{EndpointEnum.API_V1.value}/accounts/<string:account_id>/payments')


Expand Down Expand Up @@ -73,10 +73,15 @@ def post_account_payment(account_id: str):
).asdict(), HTTPStatus.CREATED
else:
is_retry_payment: bool = request.args.get('retryFailedPayment', 'false').lower() == 'true'
pay_outstanding_balance: bool = False

if flags.is_on('enable-eft-payment-method', default=False):
pay_outstanding_balance = request.args.get('payOutstandingBalance', 'false').lower() == 'true'

response, status = PaymentService.create_account_payment(
auth_account_id=account_id,
is_retry_payment=is_retry_payment
is_retry_payment=is_retry_payment,
pay_outstanding_balance=pay_outstanding_balance
).asdict(), HTTPStatus.CREATED

current_app.logger.debug('>post_account_payment')
Expand Down
107 changes: 79 additions & 28 deletions pay-api/src/pay_api/services/payment.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@
from pay_api.services.cfs_service import CFSService
from pay_api.utils.converter import Converter
from pay_api.utils.enums import (
AuthHeaderType, Code, ContentType, InvoiceReferenceStatus, PaymentMethod, PaymentStatus, PaymentSystem)
AuthHeaderType, Code, ContentType, InvoiceReferenceStatus, InvoiceStatus, PaymentMethod, PaymentStatus,
PaymentSystem)
from pay_api.utils.user_context import user_context
from pay_api.utils.util import generate_receipt_number, get_local_formatted_date, get_local_formatted_date_time

Expand Down Expand Up @@ -525,7 +526,8 @@ def find_payment_for_invoice(invoice_id: int):
return payment

@staticmethod
def create_account_payment(auth_account_id: str, is_retry_payment: bool) -> Payment:
def create_account_payment(auth_account_id: str, is_retry_payment: bool,
pay_outstanding_balance: bool = False) -> Payment:
"""Create a payment record for the account."""
payment: Payment = None
if is_retry_payment:
Expand All @@ -534,6 +536,11 @@ def create_account_payment(auth_account_id: str, is_retry_payment: bool) -> Paym
# Find all failed payments.
payments = Payment.get_failed_payments(auth_account_id)
can_consolidate_invoice: bool = True
if pay_outstanding_balance and len(payments) == 0:
# First time attempting to pay outstanding invoices and there were no previous failures
# if there is a failure payment we can follow the normal is_retry_payment flow
return Payment._consolidate_invoices_and_pay(auth_account_id)

if len(payments) == 1:
can_consolidate_invoice = False
failed_payment = payments[0]
Expand Down Expand Up @@ -588,6 +595,72 @@ def _populate(dao: PaymentModel):
payment._dao = dao # pylint: disable=protected-access
return payment

@staticmethod
def create_consolidated_invoices_payment(consolidated_invoices: List[InvoiceModel],
consolidated_line_items: List[PaymentLineItem],
cfs_account: CfsAccountModel,
pay_account: PaymentAccountModel,
invoice_total: Decimal):
"""Create payment for consolidated invoices and update invoice references."""
# Create consolidated invoice
invoice_response = CFSService.create_account_invoice(
transaction_number=str(consolidated_invoices[-1].id) + '-C',
line_items=consolidated_line_items,
cfs_account=cfs_account)

invoice_number: str = invoice_response.get('invoice_number')

# Mark all invoice references to status CANCELLED, and create a new one for the new invoice number.
for invoice in consolidated_invoices:
inv_ref: InvoiceReferenceModel = InvoiceReferenceModel.find_by_invoice_id_and_status(
invoice_id=invoice.id, status_code=InvoiceReferenceStatus.ACTIVE.value)
inv_ref.status_code = InvoiceReferenceStatus.CANCELLED.value

InvoiceReferenceModel(invoice_id=invoice.id,
status_code=InvoiceReferenceStatus.ACTIVE.value,
invoice_number=invoice_number,
reference_number=invoice_response.get('pbc_ref_number')).flush()

payment = Payment.create(payment_method=PaymentMethod.CC.value,
payment_system=PaymentSystem.PAYBC.value,
invoice_number=invoice_number,
invoice_amount=invoice_total,
payment_account_id=pay_account.id)

return payment, invoice_number

@classmethod
def _consolidate_invoices_and_pay(cls, auth_account_id: str) -> Payment:
# Find outstanding invoices and create a payment
# 1. Create new consolidated invoice in CFS.
# 2. Create new invoice reference records.
# 3. Create new payment records for the invoice as CREATED.

pay_account: PaymentAccountModel = PaymentAccountModel.find_by_auth_account_id(auth_account_id)
cfs_account: CfsAccountModel = CfsAccountModel.find_effective_by_account_id(pay_account.id)

outstanding_invoices: List[InvoiceModel] = (
InvoiceModel.find_invoices_by_status_for_account(pay_account.id,
[InvoiceStatus.CREATED.value,
InvoiceStatus.PARTIAL.value,
InvoiceStatus.OVERDUE.value,
InvoiceStatus.SETTLEMENT_SCHEDULED]
))
consolidated_invoices: List[InvoiceModel] = []
consolidated_line_items: List[PaymentLineItem] = []

invoice_total = Decimal('0')
for invoice in outstanding_invoices:
consolidated_invoices.append(invoice)
invoice_total += invoice.total - invoice.paid
consolidated_line_items.append(*invoice.payment_line_items)

payment, _ = cls.create_consolidated_invoices_payment(consolidated_invoices, consolidated_line_items,
cfs_account, pay_account, invoice_total)

BaseModel.commit()
return payment

@classmethod
def _consolidate_payments(cls, auth_account_id: str, failed_payments: List[PaymentModel]) -> Payment:
# If the payment is for consolidating failed payments,
Expand All @@ -614,32 +687,10 @@ def _consolidate_payments(cls, auth_account_id: str, failed_payments: List[Payme
invoice_total += invoice.total
consolidated_line_items.append(*invoice.payment_line_items)

# Create consolidated invoice
invoice_response = CFSService.create_account_invoice(
transaction_number=str(consolidated_invoices[-1].id) + '-C',
line_items=consolidated_line_items,
cfs_account=cfs_account)

invoice_number: str = invoice_response.get('invoice_number')

# Mark all invoice references to status CANCELLED, and create a new one for the new invoice number.
for invoice in consolidated_invoices:
inv_ref: InvoiceReferenceModel = InvoiceReferenceModel.find_by_invoice_id_and_status(
invoice_id=invoice.id, status_code=InvoiceReferenceStatus.ACTIVE.value)
inv_ref.status_code = InvoiceReferenceStatus.CANCELLED.value

InvoiceReferenceModel(
invoice_id=invoice.id,
status_code=InvoiceReferenceStatus.ACTIVE.value,
invoice_number=invoice_number,
reference_number=invoice_response.get('pbc_ref_number')).flush()

payment = Payment.create(
payment_method=PaymentMethod.CC.value,
payment_system=PaymentSystem.PAYBC.value,
invoice_number=invoice_number,
invoice_amount=invoice_total,
payment_account_id=pay_account.id)
payment, invoice_number = cls.create_consolidated_invoices_payment(consolidated_invoices,
consolidated_line_items,
cfs_account, pay_account,
invoice_total)

# Update all failed payment with consolidated invoice number.
for failed_payment in failed_payments:
Expand Down
7 changes: 7 additions & 0 deletions pay-api/src/pay_api/services/payment_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,13 @@ def _check_and_handle_payment_method(cls, account: PaymentAccountModel, target_p
PaymentMethod.EFT.value not in {account.payment_method, target_payment_method}:
return

# Don't allow payment method change from EFT if there is an outstanding balance
account_summary = Statement.get_summary(account.auth_account_id)
outstanding_balance = account_summary['total_invoice_due'] + account_summary['total_due']

if outstanding_balance > 0:
raise BusinessException(Error.EFT_SHORT_NAME_OUTSTANDING_BALANCE)

# Payment method has changed between EFT and other payment methods
statement_frequency = (
StatementFrequency.MONTHLY.value
Expand Down
26 changes: 23 additions & 3 deletions pay-api/src/pay_api/services/statement.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from typing import List

from flask import current_app
from sqlalchemy import func
from sqlalchemy import exists, func

from pay_api.models import EFTCredit as EFTCreditModel
from pay_api.models import EFTCreditInvoiceLink as EFTCreditInvoiceLinkModel
Expand Down Expand Up @@ -128,10 +128,10 @@ def asdict(self):
return d

@staticmethod
def find_by_account_id(auth_account_id: str, page: int, limit: int):
def find_by_account_id(auth_account_id: str, page: int, limit: int, is_owing: bool = None):
"""Find statements by account id."""
current_app.logger.debug(f'<search_purchase_history {auth_account_id}')
statements, total = StatementModel.find_all_statements_for_account(auth_account_id, page, limit)
statements, total = StatementModel.find_all_statements_for_account(auth_account_id, page, limit, is_owing)
statements = Statement.populate_overdue_from_invoices(statements)
statements_schema = StatementModelSchema()
data = {
Expand All @@ -156,6 +156,21 @@ def get_statement_template(statement: StatementModel, ordered_invoices: List[Inv

return StatementTemplate.STATEMENT_REPORT.value

@staticmethod
def get_invoices_owing_amount(auth_account_id: str):
"""Get invoices owing amount that have not been added as part of a statement yet."""
return (db.session.query(func.sum(InvoiceModel.total - InvoiceModel.paid).label('invoices_owing'))
.join(PaymentAccountModel, PaymentAccountModel.id == InvoiceModel.payment_account_id)
.filter(PaymentAccountModel.auth_account_id == auth_account_id)
.filter(InvoiceModel.invoice_status_code.in_((InvoiceStatus.SETTLEMENT_SCHEDULED.value,
InvoiceStatus.PARTIAL.value,
InvoiceStatus.CREATED.value,
InvoiceStatus.OVERDUE.value)))
.filter(~exists()
.where(StatementInvoicesModel.invoice_id == InvoiceModel.id))
.group_by(InvoiceModel.payment_account_id)
).scalar()

@staticmethod
def get_previous_statement(statement: StatementModel) -> StatementModel:
"""Get the preceding statement."""
Expand Down Expand Up @@ -271,7 +286,12 @@ def get_summary(auth_account_id: str, statement_id: str = None):
total_due = float(result.total_due) if result else 0
oldest_overdue_date = get_local_formatted_date(result.oldest_overdue_date) \
if result and result.oldest_overdue_date else None

# Unpaid invoice amount total that are not part of a statement yet
invoices_unpaid_amount = Statement.get_invoices_owing_amount(auth_account_id)

return {
'total_invoice_due': float(invoices_unpaid_amount) if invoices_unpaid_amount else 0,
'total_due': total_due,
'oldest_overdue_date': oldest_overdue_date
}
Expand Down
1 change: 1 addition & 0 deletions pay-api/src/pay_api/utils/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class Error(Enum):
EFT_SHORT_NAME_ACCOUNT_ID_REQUIRED = 'EFT_SHORT_NAME_ACCOUNT_ID_REQUIRED', HTTPStatus.BAD_REQUEST
EFT_SHORT_NAME_ALREADY_MAPPED = 'EFT_SHORT_NAME_ALREADY_MAPPED', HTTPStatus.BAD_REQUEST
EFT_SHORT_NAME_LINK_INVALID_STATUS = 'EFT_SHORT_NAME_LINK_INVALID_STATUS', HTTPStatus.BAD_REQUEST
EFT_SHORT_NAME_OUTSTANDING_BALANCE = 'EFT_SHORT_NAME_OUTSTANDING_BALANCE', HTTPStatus.BAD_REQUEST

# FAS Errors
FAS_INVALID_PAYMENT_METHOD = 'FAS_INVALID_PAYMENT_METHOD', HTTPStatus.BAD_REQUEST
Expand Down
Loading

0 comments on commit 137648c

Please sign in to comment.