From 6a08ee22dcd7713d02a03efc22235b802be750be Mon Sep 17 00:00:00 2001 From: Odysseus Chiu Date: Wed, 29 Jan 2025 13:48:28 -0800 Subject: [PATCH] 25334 - EFT Statement payment (#1882) --- jobs/payment-jobs/poetry.lock | 8 +- jobs/payment-jobs/pyproject.toml | 2 +- .../pay_api/models/eft_short_name_links.py | 28 +++ pay-api/src/pay_api/models/statement.py | 9 + .../pay_api/resources/v1/eft_short_names.py | 9 +- pay-api/src/pay_api/services/__init__.py | 2 + pay-api/src/pay_api/services/eft_service.py | 35 ++- .../pay_api/services/eft_short_name_links.py | 171 +++++++++++++++ .../src/pay_api/services/eft_short_names.py | 202 +----------------- .../src/pay_api/services/eft_statements.py | 126 +++++++++++ pay-api/src/pay_api/services/statement.py | 4 +- pay-api/src/pay_api/utils/errors.py | 4 + pay-api/src/pay_api/utils/query_util.py | 39 ++++ pay-api/src/pay_api/version.py | 2 +- .../unit/api/test_eft_payment_actions.py | 91 +++++++- .../tests/unit/api/test_eft_short_names.py | 76 ++++++- pay-queue/poetry.lock | 66 ++++-- pay-queue/pyproject.toml | 2 +- .../services/eft/eft_reconciliation.py | 3 +- .../integration/test_eft_reconciliation.py | 8 +- 20 files changed, 644 insertions(+), 243 deletions(-) create mode 100644 pay-api/src/pay_api/services/eft_short_name_links.py create mode 100644 pay-api/src/pay_api/services/eft_statements.py create mode 100644 pay-api/src/pay_api/utils/query_util.py diff --git a/jobs/payment-jobs/poetry.lock b/jobs/payment-jobs/poetry.lock index 0fd2b3295..6048d55ee 100644 --- a/jobs/payment-jobs/poetry.lock +++ b/jobs/payment-jobs/poetry.lock @@ -2249,9 +2249,9 @@ werkzeug = "^3.0.3" [package.source] type = "git" -url = "https://github.com/seeker25/sbc-pay.git" -reference = "update_deps" -resolved_reference = "475dab1c853d6f50f6e21ee18c0f2d87adafebd9" +url = "https://github.com/ochiu/sbc-pay.git" +reference = "25334-EFT-Statement-Payment" +resolved_reference = "cc2e95782971aa569458fecc5e8f7cb6d047ca60" subdirectory = "pay-api" [[package]] @@ -3519,4 +3519,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "60a44400a12ff6e947f4a5c3f718dff561ca8a6890984be5e8884c1ffe5099df" +content-hash = "8eaa01df36c2c0c3e7cdd8b482baf642925d10cfb0179c18f5ef2ffaaa3b2ebc" diff --git a/jobs/payment-jobs/pyproject.toml b/jobs/payment-jobs/pyproject.toml index 18d0111c3..357836ae0 100644 --- a/jobs/payment-jobs/pyproject.toml +++ b/jobs/payment-jobs/pyproject.toml @@ -7,7 +7,7 @@ readme = "README.md" [tool.poetry.dependencies] python = "^3.12" -pay-api = {git = "https://github.com/seeker25/sbc-pay.git", branch = "update_deps", subdirectory = "pay-api"} +pay-api = {git = "https://github.com/ochiu/sbc-pay.git", branch = "25334-EFT-Statement-Payment", subdirectory = "pay-api"} flask = "^3.0.2" flask-sqlalchemy = "^3.1.1" sqlalchemy = "^2.0.28" diff --git a/pay-api/src/pay_api/models/eft_short_name_links.py b/pay-api/src/pay_api/models/eft_short_name_links.py index 42a1c4e3a..1b719c049 100644 --- a/pay-api/src/pay_api/models/eft_short_name_links.py +++ b/pay-api/src/pay_api/models/eft_short_name_links.py @@ -116,6 +116,29 @@ def get_short_name_links_count(cls, auth_account_id) -> int: ).count() +@define +class StatementOwingSchema: # pylint: disable=too-few-public-methods + """Schema used to serialize the EFT Short name link statements owing.""" + + statement_id: int + amount_owing: Decimal + pending_payments_count: int + pending_payments_amount: Decimal + + @classmethod + def from_row(cls, row: dict): + """From row is used so we don't tightly couple to our database class. + + https://www.attrs.org/en/stable/init.html + """ + return cls( + statement_id=row.get("statement_id"), + amount_owing=row.get("amount_owing"), + pending_payments_count=row.get("pending_payments_count"), + pending_payments_amount=row.get("pending_payments_amount"), + ) + + @define class EFTShortnameLinkSchema: # pylint: disable=too-few-public-methods """Main schema used to serialize the EFT Short name link.""" @@ -132,6 +155,7 @@ class EFTShortnameLinkSchema: # pylint: disable=too-few-public-methods updated_by_name: str updated_on: datetime has_pending_payment: bool + statements_owing: List[StatementOwingSchema] @classmethod def from_row(cls, row: EFTShortnameLinks): @@ -139,6 +163,9 @@ def from_row(cls, row: EFTShortnameLinks): https://www.attrs.org/en/stable/init.html """ + statements = getattr(row, "statements", []) + statements_owing = [StatementOwingSchema.from_row(statement) for statement in statements or []] + return cls( id=row.id, short_name_id=row.eft_short_name_id, @@ -152,4 +179,5 @@ def from_row(cls, row: EFTShortnameLinks): updated_by_name=row.updated_by_name, updated_on=row.updated_on, has_pending_payment=bool(getattr(row, "invoice_count", 0)), + statements_owing=statements_owing, ) diff --git a/pay-api/src/pay_api/models/statement.py b/pay-api/src/pay_api/models/statement.py index 4d997c432..fba261e74 100644 --- a/pay-api/src/pay_api/models/statement.py +++ b/pay-api/src/pay_api/models/statement.py @@ -95,6 +95,15 @@ def find_all_payments_and_invoices_for_statement(cls, statement_id: str) -> List return query.all() + @classmethod + def find_statement_by_account(cls, payment_account_id: int, statement_id: int): + """Return statement for a payment account.""" + return ( + cls.query.filter(Statement.payment_account_id == payment_account_id) + .filter(Statement.id == statement_id) + .all() + ) + class StatementSchema(ma.SQLAlchemyAutoSchema): # pylint: disable=too-many-ancestors """Main schema used to serialize the Statements.""" diff --git a/pay-api/src/pay_api/resources/v1/eft_short_names.py b/pay-api/src/pay_api/resources/v1/eft_short_names.py index dc5cc1e89..d0ce31563 100644 --- a/pay-api/src/pay_api/resources/v1/eft_short_names.py +++ b/pay-api/src/pay_api/resources/v1/eft_short_names.py @@ -28,6 +28,7 @@ from pay_api.services.eft_refund import EFTRefund as EFTRefundService from pay_api.services.eft_short_name_historical import EFTShortnameHistorical as EFTShortnameHistoryService from pay_api.services.eft_short_name_historical import EFTShortnameHistorySearch +from pay_api.services.eft_short_name_links import EFTShortnameLinks as EFTShortnameLinkService from pay_api.services.eft_short_name_summaries import EFTShortnameSummaries as EFTShortnameSummariesService from pay_api.services.eft_short_names import EFTShortnames as EFTShortnameService from pay_api.services.eft_short_names import EFTShortnamesSearch @@ -173,7 +174,7 @@ def get_eft_shortname_links(short_name_id: int): response, status = {}, HTTPStatus.NOT_FOUND else: response, status = ( - EFTShortnameService.get_shortname_links(short_name_id), + EFTShortnameLinkService.get_shortname_links(short_name_id), HTTPStatus.OK, ) except BusinessException as exception: @@ -197,7 +198,7 @@ def post_eft_shortname_link(short_name_id: int): else: account_id = request_json.get("accountId", None) response, status = ( - EFTShortnameService.create_shortname_link(short_name_id, account_id), + EFTShortnameLinkService.create_shortname_link(short_name_id, account_id), HTTPStatus.OK, ) except BusinessException as exception: @@ -217,12 +218,12 @@ def patch_eft_shortname_link(short_name_id: int, short_name_link_id: int): request_json = request.get_json() try: - link = EFTShortnameService.find_link_by_id(short_name_link_id) + link = EFTShortnameLinkService.find_link_by_id(short_name_link_id) if not link or link["short_name_id"] != short_name_id: response, status = {}, HTTPStatus.NOT_FOUND else: response, status = ( - EFTShortnameService.patch_shortname_link(short_name_link_id, request_json), + EFTShortnameLinkService.patch_shortname_link(short_name_link_id, request_json), HTTPStatus.OK, ) except BusinessException as exception: diff --git a/pay-api/src/pay_api/services/__init__.py b/pay-api/src/pay_api/services/__init__.py index 6af46b6e5..f3d2915ba 100755 --- a/pay-api/src/pay_api/services/__init__.py +++ b/pay-api/src/pay_api/services/__init__.py @@ -18,8 +18,10 @@ from .eft_service import EftService from .eft_short_name_historical import EFTShortnameHistorical as EFTHistoryService from .eft_short_name_historical import EFTShortnameHistory, EFTShortnameHistorySearch +from .eft_short_name_links import EFTShortnameLinks as EFTShortNameLinkService from .eft_short_name_summaries import EFTShortnameSummaries as EFTShortNameSummaryService from .eft_short_names import EFTShortnames as EFTShortNamesService +from .eft_statements import EFTStatements as EFTStatementService from .fee_schedule import FeeSchedule from .flags import Flags from .gcp_queue import GcpQueue diff --git a/pay-api/src/pay_api/services/eft_service.py b/pay-api/src/pay_api/services/eft_service.py index 9be8fd1b6..94b89dae0 100644 --- a/pay-api/src/pay_api/services/eft_service.py +++ b/pay-api/src/pay_api/services/eft_service.py @@ -184,17 +184,29 @@ def create_receipt(invoice: InvoiceModel, payment: PaymentModel) -> ReceiptModel return receipt @staticmethod - def apply_payment_action(short_name_id: int, auth_account_id: str): + def apply_payment_action(short_name_id: int, auth_account_id: str, statement_id: int = None): """Apply EFT payments to outstanding payments.""" current_app.logger.debug("apply_payment_action") @staticmethod - def cancel_payment_action(short_name_id: int, auth_account_id: str, invoice_id: int = None): + def cancel_payment_action( + short_name_id: int, auth_account_id: str, invoice_id: int = None, statement_id: int = None + ): """Cancel EFT pending payments.""" current_app.logger.debug(" List[EFTCreditInvoiceLinkModel]: """Get short name credit invoice links by account.""" credit_links_query = ( @@ -330,10 +344,12 @@ def _get_shortname_invoice_links( EFTCreditModel.id == EFTCreditInvoiceLinkModel.eft_credit_id, ) .join(InvoiceModel, InvoiceModel.id == EFTCreditInvoiceLinkModel.invoice_id) + .join(StatementInvoicesModel, StatementInvoicesModel.invoice_id == EFTCreditInvoiceLinkModel.invoice_id) .filter(InvoiceModel.payment_account_id == payment_account_id) .filter(EFTCreditModel.short_name_id == short_name_id) .filter(EFTCreditInvoiceLinkModel.status_code.in_(statuses)) ) + credit_links_query = credit_links_query.filter_conditionally(statement_id, StatementInvoicesModel.statement_id) credit_links_query = credit_links_query.filter_conditionally(invoice_id, InvoiceModel.id) return credit_links_query.all() @@ -488,16 +504,17 @@ def apply_eft_credit(invoice_id: int, short_name_id: int, link_group_id: int, au PartnerDisbursements.handle_payment(invoice) @staticmethod - def process_owing_statements(short_name_id: int, auth_account_id: str, is_new_link: bool = False): + def process_owing_statements( + short_name_id: int, auth_account_id: str, is_new_link: bool = False, statement_id: int = None + ): """Process outstanding statement invoices for an EFT Short name.""" current_app.logger.debug(" dict: + """Get EFT short name account links.""" + current_app.logger.debug("get_shortname_links") + return {"items": link_list} + + @classmethod + def patch_shortname_link(cls, link_id: int, request: Dict): + """Patch EFT short name link.""" + current_app.logger.debug("patch_shortname_link") + return cls.find_link_by_id(link_id) + + @classmethod + @user_context + def create_shortname_link(cls, short_name_id: int, auth_account_id: str, **kwargs) -> EFTShortnameLinksModel: + """Create EFT short name auth account link.""" + current_app.logger.debug(" 0: + raise BusinessException(Error.EFT_SHORT_NAME_ALREADY_MAPPED) + + # Re-activate link if it previously existed + eft_short_name_link = EFTShortnameLinksModel.find_inactive_link(short_name_id, auth_account_id) + if eft_short_name_link is None: + eft_short_name_link = EFTShortnameLinksModel( + eft_short_name_id=short_name_id, + auth_account_id=auth_account_id, + ) + + eft_short_name_link.status_code = EFTShortnameStatus.PENDING.value + eft_short_name_link.updated_by = kwargs["user"].user_name + eft_short_name_link.updated_by_name = kwargs["user"].name + eft_short_name_link.updated_on = datetime.now(tz=timezone.utc) + + db.session.add(eft_short_name_link) + db.session.flush() + + EftService.process_owing_statements( + short_name_id=short_name_id, + auth_account_id=auth_account_id, + is_new_link=True, + ) + + eft_short_name_link.save() + current_app.logger.debug(">create_shortname_link") + return cls.find_link_by_id(eft_short_name_link.id) + + @classmethod + def delete_shortname_link(cls, short_name_link_id: int): + """Delete EFT short name auth account link.""" + current_app.logger.debug("delete_shortname_link") + + @staticmethod + def find_link_by_id(link_id: int): + """Find EFT shortname link by id.""" + current_app.logger.debug("find_link_by_id") + return result diff --git a/pay-api/src/pay_api/services/eft_short_names.py b/pay-api/src/pay_api/services/eft_short_names.py index c0929b9b7..6513a6f87 100644 --- a/pay-api/src/pay_api/services/eft_short_names.py +++ b/pay-api/src/pay_api/services/eft_short_names.py @@ -15,32 +15,29 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import date, datetime, timezone +from datetime import date from typing import Dict, List, Optional from _decimal import Decimal from flask import current_app -from sqlalchemy import and_, case, func, or_ +from sqlalchemy import case, or_ from sqlalchemy.sql.expression import exists from pay_api.exceptions import BusinessException from pay_api.models import CfsAccount as CfsAccountModel from pay_api.models import EFTShortnameLinks as EFTShortnameLinksModel -from pay_api.models import EFTShortnameLinkSchema from pay_api.models import EFTShortnames as EFTShortnameModel from pay_api.models import EFTShortnameSchema -from pay_api.models import Invoice as InvoiceModel 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 db from pay_api.utils.converter import Converter -from pay_api.utils.enums import EFTPaymentActions, EFTShortnameStatus, InvoiceStatus, PaymentMethod +from pay_api.utils.enums import EFTPaymentActions, EFTShortnameStatus, PaymentMethod from pay_api.utils.errors import Error -from pay_api.utils.user_context import user_context from pay_api.utils.util import unstructure_schema_items +from ..utils.query_util import QueryUtils from .eft_service import EftService +from .eft_statements import EFTStatements @dataclass @@ -79,9 +76,9 @@ def process_payment_action(cls, short_name_id: int, request: Dict): try: match action: case EFTPaymentActions.APPLY_CREDITS.value: - EftService.apply_payment_action(short_name_id, auth_account_id) + EftService.apply_payment_action(short_name_id, auth_account_id, statement_id) case EFTPaymentActions.CANCEL.value: - EftService.cancel_payment_action(short_name_id, auth_account_id) + EftService.cancel_payment_action(short_name_id, auth_account_id, statement_id) case EFTPaymentActions.REVERSE.value: EftService.reverse_payment_action(short_name_id, statement_id) case _: @@ -113,126 +110,6 @@ def patch_shortname(cls, short_name_id: int, request: Dict): current_app.logger.debug(">patch_shortname") return cls.find_by_short_name_id(short_name_id) - @classmethod - def patch_shortname_link(cls, link_id: int, request: Dict): - """Patch EFT short name link.""" - current_app.logger.debug("patch_shortname_link") - return cls.find_link_by_id(link_id) - - @classmethod - @user_context - def create_shortname_link(cls, short_name_id: int, auth_account_id: str, **kwargs) -> EFTShortnameLinksModel: - """Create EFT short name auth account link.""" - current_app.logger.debug("create_shortname_link") - return cls.find_link_by_id(eft_short_name_link.id) - - @classmethod - def delete_shortname_link(cls, short_name_link_id: int): - """Delete EFT short name auth account link.""" - current_app.logger.debug("delete_shortname_link") - - @classmethod - def get_shortname_links(cls, short_name_id: int) -> dict: - """Get EFT short name account links.""" - current_app.logger.debug("get_shortname_links") - return {"items": link_list} - @classmethod def find_by_short_name_id(cls, short_name_id: int) -> EFTShortnames: """Find EFT short name by short name id.""" @@ -270,17 +147,6 @@ def find_by_auth_account_id_state(cls, auth_account_id: str, state: List[str]) - current_app.logger.debug(">find_by_auth_account_id_state") return result - @classmethod - def find_link_by_id(cls, link_id: int) -> List[EFTShortnames]: - """Find EFT shortname link by id.""" - current_app.logger.debug("find_link_by_id") - return result - @classmethod def search(cls, search_criteria: EFTShortnamesSearch): """Search eft short name records.""" @@ -300,38 +166,6 @@ def search(cls, search_criteria: EFTShortnamesSearch): "total": pagination.total, } - @staticmethod - def get_statement_summary_query(): - """Query for latest statement id and total amount owing of invoices in statements.""" - return ( - db.session.query( - StatementModel.payment_account_id, - func.max(StatementModel.id).label("latest_statement_id"), - func.coalesce(func.sum(InvoiceModel.total - InvoiceModel.paid), 0).label("total_owing"), - ) - .join( - StatementInvoicesModel, - StatementInvoicesModel.statement_id == StatementModel.id, - ) - .join( - InvoiceModel, - and_( - 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 def get_search_count(cls, search_criteria: EFTShortnamesSearch): """Get total count of results based on short name state search criteria.""" @@ -346,28 +180,10 @@ def get_search_count(cls, search_criteria: EFTShortnamesSearch): current_app.logger.debug(">get_search_count") return count_query.count() - @staticmethod - def add_payment_account_name_columns(query): - """Add payment account name and branch to query select columns.""" - return query.add_columns( - case( - ( - PaymentAccountModel.name.like("%-" + PaymentAccountModel.branch_name), - func.replace( - PaymentAccountModel.name, - "-" + PaymentAccountModel.branch_name, - "", - ), - ), - else_=PaymentAccountModel.name, - ).label("account_name"), - PaymentAccountModel.branch_name.label("account_branch"), - ) - @classmethod def get_search_query(cls, search_criteria: EFTShortnamesSearch, is_count: bool = False): """Query for short names based on search criteria.""" - statement_summary_query = cls.get_statement_summary_query().subquery() + statement_summary_query = EFTStatements.get_statement_summary_query().subquery() # Case statement is to check for and remove the branch name from the name, so they can be filtered on separately # The branch name was added to facilitate a better short name search experience and the existing @@ -412,7 +228,7 @@ def get_search_query(cls, search_criteria: EFTShortnamesSearch, is_count: bool = # Join payment information if this is NOT the count query if not is_count: - links_subquery = cls.add_payment_account_name_columns(links_subquery) + links_subquery = QueryUtils.add_payment_account_name_columns(links_subquery) links_subquery = links_subquery.add_columns( statement_summary_query.c.total_owing, diff --git a/pay-api/src/pay_api/services/eft_statements.py b/pay-api/src/pay_api/services/eft_statements.py new file mode 100644 index 000000000..69dedd933 --- /dev/null +++ b/pay-api/src/pay_api/services/eft_statements.py @@ -0,0 +1,126 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Service to support EFT statements.""" +from __future__ import annotations + +from sqlalchemy import and_, func, select + +from pay_api.models import EFTCreditInvoiceLink as EFTCreditInvoiceLinkModel +from pay_api.models import Invoice as InvoiceModel +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 db +from pay_api.utils.enums import EFTCreditInvoiceStatus, InvoiceStatus, PaymentMethod + + +class EFTStatements: + """Service to support EFT statements.""" + + @staticmethod + def get_statement_summary_query(): + """Query for latest statement id and total amount owing of invoices in statements.""" + return ( + db.session.query( + StatementModel.payment_account_id, + func.max(StatementModel.id).label("latest_statement_id"), + func.coalesce(func.sum(InvoiceModel.total - InvoiceModel.paid), 0).label("total_owing"), + ) + .join( + StatementInvoicesModel, + StatementInvoicesModel.statement_id == StatementModel.id, + ) + .join( + InvoiceModel, + and_( + 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) + ) + + @staticmethod + def get_statements_owing_as_array(): + """Query EFT Statements with outstanding balances and pending payments.""" + pending_payments_subquery = ( + select( + func.count().label("cnt"), + StatementInvoicesModel.statement_id, + func.sum(EFTCreditInvoiceLinkModel.amount).label("pending_payment_amount"), + ) + .select_from(EFTCreditInvoiceLinkModel) + .join(StatementInvoicesModel, StatementInvoicesModel.invoice_id == EFTCreditInvoiceLinkModel.invoice_id) + .where(EFTCreditInvoiceLinkModel.status_code == EFTCreditInvoiceStatus.PENDING.value) + .group_by(StatementInvoicesModel.statement_id) + .alias("pending_payments") + ) + + statements_subquery = ( + select( + StatementModel.id, + StatementModel.payment_account_id, + func.sum(InvoiceModel.total - InvoiceModel.paid).label("amount_owing"), + func.coalesce(pending_payments_subquery.c.cnt, 0).label("pending_payments_count"), + func.coalesce(pending_payments_subquery.c.pending_payment_amount, 0).label("pending_payments_amount"), + ) + .select_from(StatementModel) + .join(StatementInvoicesModel, StatementInvoicesModel.statement_id == StatementModel.id) + .join(InvoiceModel, InvoiceModel.id == StatementInvoicesModel.invoice_id) + .outerjoin(pending_payments_subquery, pending_payments_subquery.c.statement_id == StatementModel.id) + .where( + and_( + StatementModel.payment_account_id == PaymentAccountModel.id, + InvoiceModel.invoice_status_code.in_([InvoiceStatus.APPROVED.value, InvoiceStatus.OVERDUE.value]), + InvoiceModel.payment_method_code == PaymentMethod.EFT.value, + ) + ) + .group_by( + StatementModel.id, + StatementModel.payment_account_id, + pending_payments_subquery.c.cnt, + pending_payments_subquery.c.pending_payment_amount, + ) + .having(func.sum(InvoiceModel.total - InvoiceModel.paid) > 0) + .order_by(StatementModel.id) + .subquery() + ) + + json_array = func.json_agg( + func.json_build_object( + "statement_id", + statements_subquery.c.id, + "amount_owing", + statements_subquery.c.amount_owing, + "pending_payments_count", + statements_subquery.c.pending_payments_count, + "pending_payments_amount", + statements_subquery.c.pending_payments_amount, + ) + ) + return ( + db.session.query(PaymentAccountModel.id, json_array.label("statements")) + .join(statements_subquery, PaymentAccountModel.id == statements_subquery.c.payment_account_id) + .filter(PaymentAccountModel.payment_method == PaymentMethod.EFT.value) + .group_by(PaymentAccountModel.id) + ) diff --git a/pay-api/src/pay_api/services/statement.py b/pay-api/src/pay_api/services/statement.py index 504c1ace2..86fe2713e 100644 --- a/pay-api/src/pay_api/services/statement.py +++ b/pay-api/src/pay_api/services/statement.py @@ -194,7 +194,7 @@ def find_by_id(statement_id: int): return statement @staticmethod - def get_account_statements(auth_account_id: str, page, limit, is_owing: bool = None): + def get_account_statements(auth_account_id: str, page, limit, is_owing: bool = None, statement_id: int = None): """Return all active statements for an account.""" query = ( db.session.query(StatementModel) @@ -215,6 +215,8 @@ def get_account_statements(auth_account_id: str, page, limit, is_owing: bool = N else: query = query.add_columns(literal(0).label("amount_owing")) + query = query.filter_conditionally(statement_id, StatementModel.id) + frequency_case = case( ( StatementModel.frequency == StatementFrequency.MONTHLY.value, diff --git a/pay-api/src/pay_api/utils/errors.py b/pay-api/src/pay_api/utils/errors.py index 0713f3469..998a3bc19 100644 --- a/pay-api/src/pay_api/utils/errors.py +++ b/pay-api/src/pay_api/utils/errors.py @@ -94,6 +94,10 @@ class Error(Enum): "EFT_PAYMENT_ACTION_ACCOUNT_ID_REQUIRED", HTTPStatus.BAD_REQUEST, ) + EFT_PAYMENT_ACTION_STATEMENT_ID_INVALID = ( + "EFT_PAYMENT_ACTION_STATEMENT_ID_INVALID", + HTTPStatus.BAD_REQUEST, + ) EFT_PAYMENT_ACTION_STATEMENT_ID_REQUIRED = ( "EFT_PAYMENT_ACTION_STATEMENT_ID_REQUIRED", HTTPStatus.BAD_REQUEST, diff --git a/pay-api/src/pay_api/utils/query_util.py b/pay-api/src/pay_api/utils/query_util.py new file mode 100644 index 000000000..17fc6e896 --- /dev/null +++ b/pay-api/src/pay_api/utils/query_util.py @@ -0,0 +1,39 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Utility for common query operations.""" +from sqlalchemy import case, func + +from pay_api.models import PaymentAccount as PaymentAccountModel + + +class QueryUtils: + """Used to provide common query operations.""" + + @staticmethod + def add_payment_account_name_columns(query): + """Add payment account name and branch to query select columns.""" + return query.add_columns( + case( + ( + PaymentAccountModel.name.like("%-" + PaymentAccountModel.branch_name), + func.replace( + PaymentAccountModel.name, + "-" + PaymentAccountModel.branch_name, + "", + ), + ), + else_=PaymentAccountModel.name, + ).label("account_name"), + PaymentAccountModel.branch_name.label("account_branch"), + ) diff --git a/pay-api/src/pay_api/version.py b/pay-api/src/pay_api/version.py index 382dda7bd..56dca1767 100644 --- a/pay-api/src/pay_api/version.py +++ b/pay-api/src/pay_api/version.py @@ -22,4 +22,4 @@ Development release segment: .devN """ -__version__ = "1.22.14" # pylint: disable=invalid-name +__version__ = "1.22.15" # pylint: disable=invalid-name diff --git a/pay-api/tests/unit/api/test_eft_payment_actions.py b/pay-api/tests/unit/api/test_eft_payment_actions.py index 949c4fc66..d22a67b18 100755 --- a/pay-api/tests/unit/api/test_eft_payment_actions.py +++ b/pay-api/tests/unit/api/test_eft_payment_actions.py @@ -31,7 +31,7 @@ from pay_api.models import PartnerDisbursements from pay_api.models import PaymentAccount as PaymentAccountModel from pay_api.models import Statement as StatementModel -from pay_api.services import EftService +from pay_api.services import EftService, EFTShortNameLinkService from pay_api.utils.enums import ( DisbursementStatus, EFTCreditInvoiceStatus, @@ -430,3 +430,92 @@ def test_eft_reverse_payment_action(db, session, client, jwt, app, admin_users_m partner_disbursement = PartnerDisbursements.query.order_by(PartnerDisbursements.id.desc()).first() assert partner_disbursement.is_reversal is True assert partner_disbursement.amount == 100 + + +def test_eft_payment_actions_on_statement(db, session, client, jwt, app): + """Assert that EFT payment apply credits / cancel action works on specific statements.""" + token = jwt.create_jwt(get_claims(roles=[Role.MANAGE_EFT.value]), token_header) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} + + account, short_name = setup_account_shortname_data() + statement1 = setup_statement_data(account=account, invoice_totals=[50]) + statement2 = setup_statement_data(account=account, invoice_totals=[100]) + eft_credits = setup_eft_credits(short_name=short_name, credit_amounts=[50]) + + rv = client.post( + f"/api/v1/eft-shortnames/{short_name.id}/payment", + data=json.dumps( + { + "action": EFTPaymentActions.APPLY_CREDITS.value, + "accountId": account.auth_account_id, + "statementId": statement1.id, + } + ), + headers=headers, + ) + assert rv.status_code == 204 + assert eft_credits[0].remaining_amount == 0 + assert EFTCreditModel.get_eft_credit_balance(short_name.id) == 0 + + credit_invoice_links = EftService._get_shortname_invoice_links( + short_name.id, account.id, [EFTCreditInvoiceStatus.PENDING.value] + ) + assert credit_invoice_links + assert len(credit_invoice_links) == 1 + link_group_id = credit_invoice_links[0].link_group_id + assert all(link.link_group_id == link_group_id and link.link_group_id is not None for link in credit_invoice_links) + + links = EFTShortNameLinkService.get_shortname_links(short_name.id) + assert links + assert len(links["items"]) == 1 + assert len(links["items"][0]["statements_owing"]) == 2 + + statement_owing1 = links["items"][0]["statements_owing"][0] + assert statement_owing1["statement_id"] == statement1.id + assert statement_owing1["amount_owing"] == 50 + assert statement_owing1["pending_payments_amount"] == 50 + assert statement_owing1["pending_payments_count"] == 1 + + statement_owing2 = links["items"][0]["statements_owing"][1] + assert statement_owing2["statement_id"] == statement2.id + assert statement_owing2["amount_owing"] == 100 + assert statement_owing2["pending_payments_amount"] == 0 + assert statement_owing2["pending_payments_count"] == 0 + + rv = client.post( + f"/api/v1/eft-shortnames/{short_name.id}/payment", + data=json.dumps( + { + "action": EFTPaymentActions.CANCEL.value, + "accountId": account.auth_account_id, + "statementId": statement1.id, + } + ), + headers=headers, + ) + + assert rv.status_code == 204 + assert eft_credits[0].remaining_amount == 50 + assert EFTCreditModel.get_eft_credit_balance(short_name.id) == 50 + + credit_invoice_links = EftService._get_shortname_invoice_links( + short_name.id, account.id, [EFTCreditInvoiceStatus.PENDING.value] + ) + assert not credit_invoice_links + + links = EFTShortNameLinkService.get_shortname_links(short_name.id) + assert links + assert len(links["items"]) == 1 + assert len(links["items"][0]["statements_owing"]) == 2 + + statement_owing1 = links["items"][0]["statements_owing"][0] + assert statement_owing1["statement_id"] == statement1.id + assert statement_owing1["amount_owing"] == 50 + assert statement_owing1["pending_payments_amount"] == 0 + assert statement_owing1["pending_payments_count"] == 0 + + statement_owing2 = links["items"][0]["statements_owing"][1] + assert statement_owing2["statement_id"] == statement2.id + assert statement_owing2["amount_owing"] == 100 + assert statement_owing2["pending_payments_amount"] == 0 + assert statement_owing2["pending_payments_count"] == 0 diff --git a/pay-api/tests/unit/api/test_eft_short_names.py b/pay-api/tests/unit/api/test_eft_short_names.py index e832a62b5..b4e196983 100755 --- a/pay-api/tests/unit/api/test_eft_short_names.py +++ b/pay-api/tests/unit/api/test_eft_short_names.py @@ -38,6 +38,7 @@ EFTCreditInvoiceStatus, EFTFileLineType, EFTHistoricalTypes, + EFTPaymentActions, EFTProcessStatus, EFTShortnameRefundStatus, EFTShortnameStatus, @@ -269,7 +270,13 @@ def test_get_eft_short_name_links(session, client, jwt, app): ).save() short_name = factory_eft_shortname(short_name="TESTSHORTNAME").save() - invoice = factory_invoice(account, payment_method_code=PaymentMethod.EFT.value, total=50, paid=0).save() + invoice = factory_invoice( + payment_account=account, + payment_method_code=PaymentMethod.EFT.value, + total=50, + paid=0, + status_code=InvoiceStatus.APPROVED.value, + ).save() statement_settings = factory_statement_settings( payment_account_id=account.id, frequency=StatementFrequency.MONTHLY.value ) @@ -309,6 +316,7 @@ def test_get_eft_short_name_links(session, client, jwt, app): assert len(link_list_dict["items"]) == 1 link = link_list_dict["items"][0] + statements_owing = link["statementsOwing"] assert link["accountId"] == account.auth_account_id assert link["id"] == link_dict["id"] assert link["shortNameId"] == short_name.id @@ -319,6 +327,72 @@ def test_get_eft_short_name_links(session, client, jwt, app): assert link["statementId"] == statement.id assert link["statusCode"] == EFTShortnameStatus.PENDING.value assert link["updatedBy"] == "IDIR/JSMITH" + assert statements_owing + assert statements_owing[0]["amountOwing"] == invoice.total + assert statements_owing[0]["pendingPaymentsAmount"] == 0 + assert statements_owing[0]["pendingPaymentsCount"] == 0 + assert statements_owing[0]["statementId"] == statement.id + + invoice2 = factory_invoice( + payment_account=account, + payment_method_code=PaymentMethod.EFT.value, + total=100, + paid=0, + status_code=InvoiceStatus.APPROVED.value, + ).save() + statement2 = factory_statement( + payment_account_id=account.id, + frequency=StatementFrequency.MONTHLY.value, + statement_settings_id=statement_settings.id, + ) + factory_statement_invoices(statement_id=statement2.id, invoice_id=invoice2.id) + eft_file = factory_eft_file() + factory_eft_credit( + eft_file_id=eft_file.id, short_name_id=short_name.id, amount=invoice.total, remaining_amount=invoice.total + ) + + rv = client.post( + f"/api/v1/eft-shortnames/{short_name.id}/payment", + data=json.dumps( + { + "action": EFTPaymentActions.APPLY_CREDITS.value, + "accountId": account.auth_account_id, + "statementId": statement.id, + } + ), + headers=headers, + ) + + rv = client.get(f"/api/v1/eft-shortnames/{short_name.id}/links", headers=headers) + + link_list_dict = rv.json + assert rv.status_code == 200 + assert link_list_dict is not None + assert link_list_dict["items"] is not None + assert len(link_list_dict["items"]) == 1 + + link = link_list_dict["items"][0] + statements_owing = link["statementsOwing"] + assert link["accountId"] == account.auth_account_id + assert link["id"] == link_dict["id"] + assert link["shortNameId"] == short_name.id + assert link["accountId"] == account.auth_account_id + assert link["accountName"] == "ABC" + assert link["accountBranch"] == "123" + assert link["amountOwing"] == invoice.total + invoice2.total + assert link["statementId"] == statement2.id + assert link["statusCode"] == EFTShortnameStatus.PENDING.value + assert link["updatedBy"] == "IDIR/JSMITH" + assert statements_owing + assert len(statements_owing) == 2 + assert statements_owing[0]["amountOwing"] == invoice.total + assert statements_owing[0]["pendingPaymentsAmount"] == 0 + assert statements_owing[0]["pendingPaymentsCount"] == 0 + assert statements_owing[0]["statementId"] == statement.id + assert statements_owing[1]["amountOwing"] == invoice2.total + assert statements_owing[1]["pendingPaymentsAmount"] == 0 + assert statements_owing[1]["pendingPaymentsCount"] == 0 + assert statements_owing[1]["statementId"] == statement2.id def assert_short_name_summary( diff --git a/pay-queue/poetry.lock b/pay-queue/poetry.lock index ec1cc4b8a..8a26af81c 100644 --- a/pay-queue/poetry.lock +++ b/pay-queue/poetry.lock @@ -330,13 +330,13 @@ redis = ["redis (>=2.10.5)"] [[package]] name = "cachelib" -version = "0.9.0" +version = "0.13.0" description = "A collection of cache libraries in the same API interface." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "cachelib-0.9.0-py3-none-any.whl", hash = "sha256:811ceeb1209d2fe51cd2b62810bd1eccf70feba5c52641532498be5c675493b3"}, - {file = "cachelib-0.9.0.tar.gz", hash = "sha256:38222cc7c1b79a23606de5c2607f4925779e37cdcea1c2ad21b8bae94b5425a5"}, + {file = "cachelib-0.13.0-py3-none-any.whl", hash = "sha256:8c8019e53b6302967d4e8329a504acf75e7bc46130291d30188a6e4e58162516"}, + {file = "cachelib-0.13.0.tar.gz", hash = "sha256:209d8996e3c57595bee274ff97116d1d73c4980b2fd9a34c7846cd07fd2e1a48"}, ] [[package]] @@ -905,20 +905,24 @@ async = ["asgiref (>=3.2)"] dotenv = ["python-dotenv"] [[package]] -name = "flask-caching" +name = "Flask-Caching" version = "2.3.0" description = "Adds caching support to Flask applications." optional = false python-versions = ">=3.8" -files = [ - {file = "Flask_Caching-2.3.0-py3-none-any.whl", hash = "sha256:51771c75682e5abc1483b78b96d9131d7941dc669b073852edfa319dd4e29b6e"}, - {file = "flask_caching-2.3.0.tar.gz", hash = "sha256:d7e4ca64a33b49feb339fcdd17e6ba25f5e01168cf885e53790e885f83a4d2cf"}, -] +files = [] +develop = false [package.dependencies] -cachelib = ">=0.9.0,<0.10.0" +cachelib = ">=0.9.0" Flask = "*" +[package.source] +type = "git" +url = "https://github.com/pallets-eco/flask-caching.git" +reference = "master" +resolved_reference = "494d49882537a6cbcfe3cb41c4df05ae8acf60ce" + [[package]] name = "flask-cors" version = "5.0.0" @@ -952,24 +956,25 @@ test = ["flask-sqlalchemy", "pytest"] [[package]] name = "flask-jwt-oidc" -version = "0.7.0" +version = "0.8.1" description = "Opinionated flask oidc client" optional = false -python-versions = "^3.9" +python-versions = ">=3.9,<4" files = [] develop = false [package.dependencies] -cachelib = "0.*" +cachelib = "^0.13.0" +cryptography = ">=3.4.0" Flask = ">=2" -python-jose = "^3.3.0" +pyjwt = "^2.8.0" six = "^1.16.0" [package.source] type = "git" url = "https://github.com/seeker25/flask-jwt-oidc.git" -reference = "HEAD" -resolved_reference = "563f01ef6453eb0ea1cc0a2d71c6665350c853ff" +reference = "main" +resolved_reference = "bba7bb26625b213f4be817b01f28fb8bbb5b05d1" [[package]] name = "flask-marshmallow" @@ -2008,7 +2013,7 @@ aiohttp = "^3.10.11" alembic = "1.13.1" attrs = "23.2.0" blinker = "1.7.0" -cachelib = "0.9.0" +cachelib = "^0.13.0" cachetools = "5.3.3" cattrs = "23.2.3" certifi = "2024.7.4" @@ -2021,10 +2026,10 @@ dpath = "2.1.6" ecdsa = "0.18.0" expiringdict = "1.2.2" flask = "3.0.2" -flask-caching = "2.3.0" +flask-caching = {git = "https://github.com/pallets-eco/flask-caching.git", branch = "master"} flask-cors = "5.0.0" flask-executor = "^1.0.0" -flask-jwt-oidc = {git = "https://github.com/seeker25/flask-jwt-oidc.git"} +flask-jwt-oidc = {git = "https://github.com/seeker25/flask-jwt-oidc.git", branch = "main"} flask-marshmallow = "1.2.0" flask-migrate = "4.0.7" flask-moment = "1.0.5" @@ -2081,8 +2086,8 @@ werkzeug = "^3.0.3" [package.source] type = "git" url = "https://github.com/ochiu/sbc-pay.git" -reference = "23812" -resolved_reference = "1fd3d251e9da4c47b0d9d0ecbcdc0e696871a1f2" +reference = "25334-EFT-Statement-Payment" +resolved_reference = "cc2e95782971aa569458fecc5e8f7cb6d047ca60" subdirectory = "pay-api" [[package]] @@ -2509,6 +2514,23 @@ files = [ {file = "pyhumps-3.8.0.tar.gz", hash = "sha256:498026258f7ee1a8e447c2e28526c0bea9407f9a59c03260aee4bd6c04d681a3"}, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, + {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + [[package]] name = "pylint" version = "3.2.6" @@ -3289,4 +3311,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "44319e688a7a5f4e67b38889bff7221bcd81dd2ebdd07cf9915d79aa612da347" +content-hash = "4193f48e6e6d170e4a80c501b31a1be04a53350b305be54d4634419df8cdb200" diff --git a/pay-queue/pyproject.toml b/pay-queue/pyproject.toml index 438fb2747..684b61295 100644 --- a/pay-queue/pyproject.toml +++ b/pay-queue/pyproject.toml @@ -19,7 +19,7 @@ itsdangerous = "^2.1.2" protobuf = "4.25.3" launchdarkly-server-sdk = "^8.2.1" cachecontrol = "^0.14.0" -pay-api = {git = "https://github.com/ochiu/sbc-pay.git", branch = "23812", subdirectory = "pay-api"} +pay-api = {git = "https://github.com/ochiu/sbc-pay.git", branch = "25334-EFT-Statement-Payment", subdirectory = "pay-api"} pg8000 = "^1.30.5" diff --git a/pay-queue/src/pay_queue/services/eft/eft_reconciliation.py b/pay-queue/src/pay_queue/services/eft/eft_reconciliation.py index 6193c8281..af3692171 100644 --- a/pay-queue/src/pay_queue/services/eft/eft_reconciliation.py +++ b/pay-queue/src/pay_queue/services/eft/eft_reconciliation.py @@ -23,6 +23,7 @@ from pay_api.models import EFTTransaction as EFTTransactionModel from pay_api.services.eft_short_name_historical import EFTShortnameHistorical as EFTHistoryService from pay_api.services.eft_short_name_historical import EFTShortnameHistory as EFTHistory +from pay_api.services.eft_short_name_links import EFTShortnameLinks as EFTShortnameLinksService from pay_api.services.eft_short_names import EFTShortnames as EFTShortnamesService from pay_api.utils.enums import EFTFileLineType, EFTPaymentActions, EFTProcessStatus, EFTShortnameType from sentry_sdk import capture_message @@ -217,7 +218,7 @@ def _apply_eft_pending_payments(context: EFTReconciliation, shortname_balance): continue eft_credit_balance = EFTCreditModel.get_eft_credit_balance(eft_short_name.id) - shortname_links = EFTShortnamesService.get_shortname_links(eft_short_name.id).get("items", []) + shortname_links = EFTShortnameLinksService.get_shortname_links(eft_short_name.id).get("items", []) # Don't apply auto payments for multi link accounts if len(shortname_links) > 1: diff --git a/pay-queue/tests/integration/test_eft_reconciliation.py b/pay-queue/tests/integration/test_eft_reconciliation.py index 32cbab998..e22d6633b 100644 --- a/pay-queue/tests/integration/test_eft_reconciliation.py +++ b/pay-queue/tests/integration/test_eft_reconciliation.py @@ -29,7 +29,7 @@ from pay_api.models import EFTTransaction as EFTTransactionModel from pay_api.models import Invoice as InvoiceModel from pay_api.models import PaymentAccount as PaymentAccountModel -from pay_api.services import EFTShortNamesService +from pay_api.services import EFTShortNameLinkService, EFTShortNamesService from pay_api.utils.enums import ( EFTCreditInvoiceStatus, EFTFileLineType, @@ -1027,7 +1027,7 @@ def test_apply_pending_payments(session, app, client): eft_credit_balance = EFTCreditModel.get_eft_credit_balance(short_name_id) assert eft_credit_balance == 0 - short_name_links = EFTShortNamesService.get_shortname_links(short_name_id) + short_name_links = EFTShortNameLinkService.get_shortname_links(short_name_id) assert short_name_links["items"] assert len(short_name_links["items"]) == 1 @@ -1061,7 +1061,7 @@ def test_multi_link_apply_pending_payments(session, app, client): eft_credit_balance = EFTCreditModel.get_eft_credit_balance(short_name_id) assert eft_credit_balance == 150.50 - short_name_links = EFTShortNamesService.get_shortname_links(short_name_id) + short_name_links = EFTShortNameLinkService.get_shortname_links(short_name_id) assert short_name_links["items"] assert len(short_name_links["items"]) == 2 @@ -1118,7 +1118,7 @@ def test_skip_on_insufficient_balance(session, app, client): eft_credit_balance = EFTCreditModel.get_eft_credit_balance(short_name_id) assert eft_credit_balance == 150.50 - short_name_links = EFTShortNamesService.get_shortname_links(short_name_id) + short_name_links = EFTShortNameLinkService.get_shortname_links(short_name_id) assert short_name_links["items"] assert len(short_name_links["items"]) == 1