Skip to content

Commit

Permalink
18493 EFT Statement updates (#1354)
Browse files Browse the repository at this point in the history
- add payment methods based on payment account history per statement
- add invoice for statement additional filtering to support payment job updates for generating statements
- logic to generate interim statement when switching to EFT payment method
  • Loading branch information
ochiu authored Dec 19, 2023
1 parent 2ba80db commit d898680
Show file tree
Hide file tree
Showing 7 changed files with 270 additions and 17 deletions.
14 changes: 13 additions & 1 deletion pay-api/src/pay_api/models/payment.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import pytz
from flask import current_app
from marshmallow import fields
from sqlalchemy import Boolean, ForeignKey, String, cast, func, or_, text
from sqlalchemy import Boolean, ForeignKey, String, and_, cast, func, not_, or_, text
from sqlalchemy.orm import contains_eager, lazyload, load_only, relationship
from sqlalchemy.sql.expression import literal

Expand Down Expand Up @@ -267,6 +267,18 @@ def get_invoices_for_statements(cls, search_filter: Dict):
query = db.session.query(Invoice) \
.join(PaymentAccount, Invoice.payment_account_id == PaymentAccount.id)\
.filter(PaymentAccount.auth_account_id.in_(search_filter.get('authAccountIds', [])))

# If an account is within these payment methods - limit invoices to these payment methods.
# Used for transitioning payment method and an interim statement is created (There could be different payment
# methods for the transition day and we don't want it on both statements)
if payment_methods := search_filter.get('matchPaymentMethods', None):
query = query.filter(
or_(
not_(PaymentAccount.payment_method.in_(payment_methods)),
and_(PaymentAccount.payment_method.in_(payment_methods),
Invoice.payment_method_code == PaymentAccount.payment_method)
))

query = cls.filter_date(query, search_filter).with_entities(Invoice.id, PaymentAccount.auth_account_id)
return query.all()

Expand Down
26 changes: 25 additions & 1 deletion pay-api/src/pay_api/models/statement.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@

import pytz
from marshmallow import fields
from sqlalchemy import ForeignKey, and_, case, literal_column
from sqlalchemy import ForeignKey, and_, case, func, literal_column, or_
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import aliased
from sqlalchemy_continuum import transaction_class, version_class

from pay_api.utils.constants import LEGISLATIVE_TIMEZONE
from pay_api.utils.enums import StatementFrequency
Expand Down Expand Up @@ -66,6 +69,26 @@ class Statement(BaseModel):
notification_status_code = db.Column(db.String(20), ForeignKey('notification_status_codes.code'), nullable=True)
notification_date = db.Column(db.Date, default=None, nullable=True)

@hybrid_property
def payment_methods(self):
"""Return all payment methods that were active during the statement period based on payment account versions."""
payment_account_version = version_class(PaymentAccount)
transaction_start = aliased(transaction_class(PaymentAccount))
transaction_end = aliased(transaction_class(PaymentAccount))

subquery = db.session.query(func.array_agg(func.DISTINCT(payment_account_version.payment_method))
.label('payment_methods'))\
.join(Statement, Statement.payment_account_id == payment_account_version.id)\
.join(transaction_start, payment_account_version.transaction_id == transaction_start.id)\
.outerjoin(transaction_end, payment_account_version.end_transaction_id == transaction_end.id)\
.filter(payment_account_version.id == self.payment_account_id) \
.filter(and_(Statement.id == self.id, transaction_start.issued_at <= Statement.to_date,
or_(transaction_end.issued_at >= Statement.from_date,
transaction_end.id.is_(None))))\
.group_by(Statement.id).first()

return subquery[0] if subquery else []

@classmethod
def find_all_statements_for_account(cls, auth_account_id: str, page, limit):
"""Return all active statements for an account."""
Expand Down Expand Up @@ -126,3 +149,4 @@ class Meta: # pylint: disable=too-few-public-methods
from_date = fields.Date(tzinfo=pytz.timezone(LEGISLATIVE_TIMEZONE))
to_date = fields.Date(tzinfo=pytz.timezone(LEGISLATIVE_TIMEZONE))
is_overdue = fields.Boolean()
payment_methods = fields.List(fields.String())
20 changes: 18 additions & 2 deletions pay-api/src/pay_api/services/payment_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"""Service to manage Payment Account model related operations."""
from __future__ import annotations

from datetime import datetime, timedelta, timezone
from datetime import date, datetime, timedelta, timezone
from decimal import Decimal
from typing import Any, Dict, List, Optional, Tuple

Expand All @@ -37,6 +37,7 @@
from pay_api.services.distribution_code import DistributionCode
from pay_api.services.oauth_service import OAuthService
from pay_api.services.queue_publisher import publish_response
from pay_api.services.statement import Statement
from pay_api.services.statement_settings import StatementSettings
from pay_api.utils.enums import (
AuthHeaderType, CfsAccountStatus, ContentType, InvoiceStatus, MessageType, PaymentMethod, PaymentSystem,
Expand All @@ -46,6 +47,8 @@
from pay_api.utils.util import (
current_local_time, get_local_formatted_date, get_outstanding_txns_from_date, get_str_by_path, mask)

from .flags import flags


class PaymentAccount(): # pylint: disable=too-many-instance-attributes, too-many-public-methods
"""Service to manage Payment Account model related operations."""
Expand Down Expand Up @@ -357,6 +360,15 @@ def create(cls, account_request: Dict[str, Any] = None, is_sandbox: bool = False
current_app.logger.debug('>create payment account')
return payment_account

@classmethod
def _check_and_handle_payment_method(cls, account: PaymentAccountModel, target_payment_method: str):
"""Check if the payment method has changed and invoke handling logic."""
if account.payment_method == target_payment_method or target_payment_method != PaymentMethod.EFT.value:
return

# Payment method has changed and is EFT
Statement.generate_interim_statement(account.auth_account_id, StatementFrequency.MONTHLY.value)

@classmethod
def _check_and_update_statement_settings(cls, payment_account: PaymentAccountModel):
"""Check and update statement settings based on payment method."""
Expand Down Expand Up @@ -423,6 +435,9 @@ def _save_account(cls, account_request: Dict[str, any], payment_account: Payment

# If the payment method is CC, set the payment_method as DIRECT_PAY
if payment_method := get_str_by_path(account_request, 'paymentInfo/methodOfPayment'):
if flags.is_on('enable-eft-payment-method', default=False):
cls._check_and_handle_payment_method(payment_account, payment_method)

payment_account.payment_method = payment_method
payment_account.bcol_account = account_request.get('bcolAccountNumber', None)
payment_account.bcol_user_id = account_request.get('bcolUserId', None)
Expand Down Expand Up @@ -604,7 +619,8 @@ def _persist_default_statement_frequency(payment_account_id):

statement_settings_model = StatementSettingsModel(
frequency=frequency,
payment_account_id=payment_account_id
payment_account_id=payment_account_id,
from_date=date.today() # To help with mocking tests - freeze_time doesn't seem to work on the model default
)
statement_settings_model.save()

Expand Down
69 changes: 65 additions & 4 deletions pay-api/src/pay_api/services/statement.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,24 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""Service class to control all the operations related to statements."""
from datetime import date, datetime
from datetime import date, datetime, timedelta
from typing import List

from flask import current_app
from sqlalchemy import func

from pay_api.models import db
from pay_api.models import Invoice as InvoiceModel
from pay_api.models import Payment as PaymentModel
from pay_api.models import PaymentAccount as PaymentAccountModel
from pay_api.models import Statement as StatementModel
from pay_api.models import StatementInvoices as StatementInvoicesModel
from pay_api.models import StatementSchema as StatementModelSchema
from pay_api.utils.enums import ContentType, InvoiceStatus, StatementFrequency
from pay_api.models import StatementSettings as StatementSettingsModel
from pay_api.models import db
from pay_api.utils.constants import DT_SHORT_FORMAT
from pay_api.utils.util import get_local_formatted_date
from pay_api.utils.enums import ContentType, InvoiceStatus, NotificationStatus, StatementFrequency
from pay_api.utils.util import get_first_and_last_of_frequency, get_local_formatted_date, get_local_time

from .payment import Payment as PaymentService


Expand Down Expand Up @@ -219,3 +222,61 @@ def populate_overdue_from_invoices(statements: List[StatementModel]):
for statement in statements:
statement.is_overdue = overdue_statements.get(statement.id, 0) > 0
return statements

@staticmethod
def generate_interim_statement(auth_account_id: str, new_frequency: str):
"""Generate interim statement."""
today = get_local_time(datetime.today())

# This can happen during account creation when the default active settings do not exist yet
# No interim statement is needed in this case
if not (active_settings := StatementSettingsModel.find_active_settings(str(auth_account_id), today)):
return None

account: PaymentAccountModel = PaymentAccountModel.find_by_auth_account_id(auth_account_id)

# End the current statement settings
active_settings.to_date = today
active_settings.save()
statement_from, statement_to = get_first_and_last_of_frequency(today, active_settings.frequency)
statement_filter = {
'dateFilter': {
'startDate': statement_from.strftime('%Y-%m-%d'),
'endDate': statement_to.strftime('%Y-%m-%d')
},
'authAccountIds': [account.auth_account_id]
}

# Generate interim statement
statement = StatementModel(
frequency=active_settings.frequency,
statement_settings_id=active_settings.id,
payment_account_id=account.id,
created_on=today,
from_date=statement_from,
to_date=today,
notification_status_code=NotificationStatus.PENDING.value
if account.statement_notification_enabled else NotificationStatus.SKIP.value
).save()

invoices_and_auth_ids = PaymentModel.get_invoices_for_statements(statement_filter)
invoices = list(invoices_and_auth_ids)

statement_invoices = [StatementInvoicesModel(
statement_id=statement.id,
invoice_id=invoice.id
) for invoice in invoices]

db.session.bulk_save_objects(statement_invoices)

# Create new statement settings for the transition
latest_settings = StatementSettingsModel.find_latest_settings(str(auth_account_id))
if latest_settings is None or latest_settings.id == active_settings.id:
latest_settings = StatementSettingsModel()

latest_settings.frequency = new_frequency
latest_settings.payment_account_id = account.id
latest_settings.from_date = today + timedelta(days=1)
latest_settings.save()

return statement
11 changes: 10 additions & 1 deletion pay-api/src/pay_api/utils/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from flask import current_app

from .constants import DT_SHORT_FORMAT
from .enums import CorpType
from .enums import CorpType, StatementFrequency


def cors_preflight(methods: str = 'GET'):
Expand Down Expand Up @@ -110,6 +110,15 @@ def get_previous_day(val: datetime):
return val - timedelta(days=1)


def get_first_and_last_of_frequency(date: datetime, frequency: str):
"""Return first day of the specified frequency."""
if frequency == StatementFrequency.MONTHLY.value:
return get_first_and_last_dates_of_month(date.month, date.year)
if frequency == StatementFrequency.WEEKLY.value:
return get_week_start_and_end_date(date)
return None, None


def parse_url_params(url_params: str) -> Dict:
"""Parse URL params and return dict of parsed url params."""
parsed_url: dict = {}
Expand Down
2 changes: 1 addition & 1 deletion pay-api/src/pay_api/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@
Development release segment: .devN
"""

__version__ = '1.20.4' # pylint: disable=invalid-name
__version__ = '1.20.5' # pylint: disable=invalid-name
Loading

0 comments on commit d898680

Please sign in to comment.