diff --git a/jobs/payment-jobs/poetry.lock b/jobs/payment-jobs/poetry.lock index d53cc8fbd..31547ce76 100644 --- a/jobs/payment-jobs/poetry.lock +++ b/jobs/payment-jobs/poetry.lock @@ -2022,8 +2022,8 @@ werkzeug = "3.0.3" [package.source] type = "git" url = "https://github.com/seeker25/sbc-pay.git" -reference = "logging_changes_part3" -resolved_reference = "5a6494656564bba0154aed41288ecd751f16d0c2" +reference = "22969" +resolved_reference = "0dcbdfa25862ebd09569d48602756dc332e03b5e" subdirectory = "pay-api" [[package]] @@ -3174,4 +3174,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "c6c86abce08f1407ab8893fb5b853e62114733dd0dbbfc42ff48348dde5be418" +content-hash = "d4366abd146c06f59a58d760deea1ce0aca5d9f9319ecc385683c186ba51da83" diff --git a/jobs/payment-jobs/pyproject.toml b/jobs/payment-jobs/pyproject.toml index 19ee49858..de77de686 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 = "logging_changes_part3", subdirectory = "pay-api"} +pay-api = {git = "https://github.com/seeker25/sbc-pay.git", branch = "22969", 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_credit.py b/pay-api/src/pay_api/models/eft_credit.py index 41315d8fd..11214445e 100644 --- a/pay-api/src/pay_api/models/eft_credit.py +++ b/pay-api/src/pay_api/models/eft_credit.py @@ -13,6 +13,7 @@ # limitations under the License. """Model to handle all operations related to EFT Credits data.""" from datetime import datetime, timezone +from typing import List, Self from decimal import Decimal from sqlalchemy import ForeignKey, func @@ -72,3 +73,12 @@ def get_eft_credit_balance(cls, short_name_id: int) -> Decimal: .one_or_none() return Decimal(result.credit_balance) if result else 0 + + @classmethod + def get_eft_credits(cls, short_name_id: int) -> List[Self]: + """Get EFT Credits with a remaining amount.""" + return (cls.query + .filter(EFTCredit.remaining_amount > 0) + .filter(EFTCredit.short_name_id == short_name_id) + .order_by(EFTCredit.created_on.asc()) + .all()) diff --git a/pay-api/src/pay_api/services/eft_service.py b/pay-api/src/pay_api/services/eft_service.py index 9043bb952..ecd9dc2e1 100644 --- a/pay-api/src/pay_api/services/eft_service.py +++ b/pay-api/src/pay_api/services/eft_service.py @@ -12,29 +12,33 @@ # See the License for the specific language governing permissions and # limitations under the License. """Service to manage CFS EFT Payments.""" +from __future__ import annotations + +from datetime import datetime, timezone from decimal import Decimal -from datetime import datetime from typing import Any, Dict, List from flask import current_app +from sqlalchemy import and_, func from pay_api.exceptions import BusinessException from pay_api.models import CfsAccount as CfsAccountModel -from pay_api.models import EFTCreditInvoiceLink as EFTCreditInvoiceLinkModel from pay_api.models import EFTCredit as EFTCreditModel -from pay_api.models import EFTShortnamesHistorical as EFTHistoryModel +from pay_api.models import EFTCreditInvoiceLink as EFTCreditInvoiceLinkModel from pay_api.models import EFTRefund as EFTRefundModel +from pay_api.models import EFTRefundEmailList +from pay_api.models import EFTShortnameLinks as EFTShortnameLinksModel +from pay_api.models import EFTShortnamesHistorical as EFTHistoryModel from pay_api.models import Invoice as InvoiceModel from pay_api.models import InvoiceReference as InvoiceReferenceModel from pay_api.models import Payment as PaymentModel from pay_api.models import PaymentAccount as PaymentAccountModel from pay_api.models import Receipt as ReceiptModel from pay_api.models import RefundPartialLine -from pay_api.models.eft_refund_email_list import EFTRefundEmailList -from pay_api.services.eft_short_names import EFTShortnames -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.email_service import _render_shortname_details_body, send_email +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.models.corp_type import CorpType as CorpTypeModel from pay_api.utils.enums import ( CfsAccountStatus, EFTCreditInvoiceStatus, InvoiceReferenceStatus, InvoiceStatus, PaymentMethod, PaymentStatus, PaymentSystem) @@ -42,11 +46,16 @@ from pay_api.utils.user_context import user_context from pay_api.utils.util import get_str_by_path +from .auth import get_account_admin_users from .deposit_service import DepositService +from .eft_short_name_historical import EFTShortnameHistorical as EFTHistoryService +from .eft_short_name_historical import EFTShortnameHistory as EFTHistory +from .email_service import _render_payment_reversed_template, _render_shortname_details_body, send_email from .invoice import Invoice from .invoice_reference import InvoiceReference from .payment_account import PaymentAccount from .payment_line_item import PaymentLineItem +from .statement import Statement as StatementService class EftService(DepositService): @@ -108,7 +117,6 @@ def process_cfs_refund(self, invoice: InvoiceModel, and InvoiceReferenceModel.find_by_invoice_id_and_status( invoice.id, InvoiceReferenceStatus.ACTIVE.value) is None and not cils: return InvoiceStatus.CANCELLED.value - inv_ref = InvoiceReferenceModel.find_by_invoice_id_and_status(invoice.id, InvoiceReferenceStatus.COMPLETED.value) if inv_ref and inv_ref.is_consolidated: @@ -116,6 +124,325 @@ def process_cfs_refund(self, invoice: InvoiceModel, # Also untested with EFT. We want to be able to refund back to the original payment method. raise BusinessException(Error.INVALID_CONSOLIDATED_REFUND) + return EftService._handle_invoice_refund(invoice, payment_account, cils) + + @staticmethod + def create_invoice_reference(invoice: InvoiceModel, invoice_number: str, + reference_number: str) -> InvoiceReferenceModel: + """Create an invoice reference record.""" + if not (invoice_reference := InvoiceReferenceModel + .find_any_active_reference_by_invoice_number(invoice_number)): + invoice_reference = InvoiceReferenceModel() + + invoice_reference.invoice_id = invoice.id + invoice_reference.invoice_number = invoice_number + invoice_reference.reference_number = reference_number + invoice_reference.status_code = InvoiceReferenceStatus.ACTIVE.value + + return invoice_reference + + @staticmethod + def create_receipt(invoice: InvoiceModel, payment: PaymentModel) -> ReceiptModel: + """Create a receipt record for an invoice payment.""" + receipt: ReceiptModel = ReceiptModel(receipt_date=payment.payment_date, + receipt_amount=payment.paid_amount, + invoice_id=invoice.id, + receipt_number=payment.receipt_number) + return receipt + + @staticmethod + @user_context + def create_shortname_refund(request: Dict[str, str], **kwargs) -> Dict[str, str]: + """Create refund.""" + # This method isn't for invoices, it's for shortname only. + shortname_id = get_str_by_path(request, 'shortNameId') + shortname = get_str_by_path(request, 'shortName') + amount = get_str_by_path(request, 'refundAmount') + comment = get_str_by_path(request, 'comment') + + current_app.logger.debug(f'Starting shortname refund : {shortname_id}') + + refund = EftService._create_refund_model(request, shortname_id, amount, comment) + EftService._refund_eft_credits(int(shortname_id), amount) + + recipients = EFTRefundEmailList.find_all_emails() + subject = f'Pending Refund Request for Short Name {shortname}' + html_body = _render_shortname_details_body(shortname, amount, comment, shortname_id) + + send_email(recipients, subject, html_body, **kwargs) + refund.save() + + @staticmethod + def apply_payment_action(short_name_id: int, auth_account_id: str): + """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): + """Cancel EFT pending payments.""" + current_app.logger.debug('cancel_payment_action') + + @staticmethod + def reverse_payment_action(short_name_id: int, statement_id: int): + """Reverse EFT Payments on a statement to short name EFT credits.""" + current_app.logger.debug(' 0: + # invoice_disbursements.setdefault(invoice, 0) + # invoice_disbursements[invoice] += current_link.amount + + # for invoice, total_amount in invoice_disbursements.items(): + # PartnerDisbursementsModel( + # amount=total_amount, + # is_reversal=True, + # partner_code=invoice.corp_type_code, + # status_code=DisbursementStatus.WAITING_FOR_JOB.value, + # target_id=invoice.id, + # target_type=EJVLinkType.INVOICE.value + # ).flush() + statement = StatementModel.find_by_id(statement_id) + EFTHistoryService.create_statement_reverse( + EFTHistory(short_name_id=short_name_id, + amount=reversed_credits, + 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, + hidden=False, + is_processing=True) + ).flush() + + EftService._send_reversed_payment_notification(statement, reversed_credits) + current_app.logger.debug('>reverse_payment_action') + + @staticmethod + def get_pending_payment_count(): + """Get count of pending EFT Credit Invoice Links.""" + return (db.session.query(db.func.count(InvoiceModel.id).label('invoice_count')) + .join(EFTCreditInvoiceLinkModel, EFTCreditInvoiceLinkModel.invoice_id == InvoiceModel.id) + .filter(InvoiceModel.payment_account_id == PaymentAccountModel.id) + .filter(EFTCreditInvoiceLinkModel.status_code.in_([EFTCreditInvoiceStatus.PENDING.value])) + .correlate(PaymentAccountModel) + .scalar_subquery()) + + @staticmethod + def _get_shortname_invoice_links(short_name_id: int, payment_account_id: int, + statuses: List[str], invoice_id: int = None) -> List[EFTCreditInvoiceLinkModel]: + """Get short name credit invoice links by account.""" + credit_links_query = ( + db.session.query(EFTCreditInvoiceLinkModel) + .join(EFTCreditModel, EFTCreditModel.id == EFTCreditInvoiceLinkModel.eft_credit_id) + .join(InvoiceModel, InvoiceModel.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(invoice_id, InvoiceModel.id) + return credit_links_query.all() + + @staticmethod + @user_context + def _send_reversed_payment_notification(statement: StatementModel, reversed_amount, **kwargs): + payment_account = PaymentAccountModel.find_by_id(statement.payment_account_id) + summary_dict: dict = StatementService.get_summary(payment_account.auth_account_id) + + due_date = StatementService.calculate_due_date(statement.to_date) + outstanding_balance = summary_dict['total_due'] + reversed_amount + email_params = { + 'accountId': payment_account.auth_account_id, + 'accountName': payment_account.name, + 'reversedAmount': f'{reversed_amount:,.2f}', + 'outstandingBalance': f'{outstanding_balance:,.2f}', + 'statementMonth': statement.from_date.strftime('%B'), + 'statementNumber': statement.id, + 'dueDate': datetime.fromisoformat(due_date).strftime('%B %e, %Y') + } + + org_admins_response = get_account_admin_users(payment_account.auth_account_id) + admins = org_admins_response.get('members') if org_admins_response.get('members', None) else [] + recipients = [ + admin['user']['contacts'][0]['email'] + for admin in admins + if 'user' in admin and 'contacts' in admin['user'] and admin['user']['contacts'] + ] + + send_email(recipients=recipients, + subject='Outstanding Balance Adjustment Notice', + html_body=_render_payment_reversed_template(email_params), + **kwargs) + + @staticmethod + def _validate_reversal_credit_invoice_links(statement_id: int, + credit_invoice_links: List[EFTCreditInvoiceLinkModel]): + """Validate credit invoice links for reversal.""" + invalid_link_statuses = [EFTCreditInvoiceStatus.PENDING.value, + EFTCreditInvoiceStatus.PENDING_REFUND.value, + EFTCreditInvoiceStatus.REFUNDED.value] + + # We are reversing all invoices associated to a statement, if any links are in transition state or already + # refunded we should not allow a statement reversal + unprocessable_links = [link for link in credit_invoice_links if link.status_code in invalid_link_statuses] + if unprocessable_links: + raise BusinessException(Error.EFT_PAYMENT_ACTION_CREDIT_LINK_STATUS_INVALID) + # Validate when statement paid date can't be older than 60 days + min_payment_date = ( + db.session.query(func.min(InvoiceModel.payment_date)) + .join(StatementInvoicesModel, StatementInvoicesModel.invoice_id == InvoiceModel.id) + .filter(StatementInvoicesModel.statement_id == statement_id) + .filter(InvoiceModel.payment_method_code == PaymentMethod.EFT.value) + .scalar() + ) + + if min_payment_date is None: + raise BusinessException(Error.EFT_PAYMENT_ACTION_UNPAID_STATEMENT) + + date_difference = datetime.now(tz=timezone.utc) - min_payment_date.replace(tzinfo=timezone.utc) + if date_difference.days > 60: + raise BusinessException(Error.EFT_PAYMENT_ACTION_REVERSAL_EXCEEDS_SIXTY_DAYS) + + @staticmethod + def get_statement_credit_invoice_links(shortname_id, statement_id) -> List[EFTCreditInvoiceLinkModel]: + """Get most recent EFT Credit invoice links associated to a statement and short name.""" + query = (db.session.query(EFTCreditInvoiceLinkModel) + .distinct(EFTCreditInvoiceLinkModel.invoice_id) + .join(EFTCreditModel, EFTCreditModel.id == EFTCreditInvoiceLinkModel.eft_credit_id) + .join(StatementInvoicesModel, + StatementInvoicesModel.invoice_id == EFTCreditInvoiceLinkModel.invoice_id) + .filter(StatementInvoicesModel.statement_id == statement_id) + .filter(EFTCreditModel.short_name_id == shortname_id) + .filter(EFTCreditInvoiceLinkModel.status_code != EFTCreditInvoiceStatus.CANCELLED.value) + .order_by(EFTCreditInvoiceLinkModel.invoice_id.desc(), + EFTCreditInvoiceLinkModel.created_on.desc(), + EFTCreditInvoiceLinkModel.id.desc()) + ) + return query.all() + + @staticmethod + def apply_eft_credit(invoice_id: int, + short_name_id: int, + link_group_id: int, + auto_save: bool = False): + """Apply EFT credit and update remaining credit records.""" + invoice = InvoiceModel.find_by_id(invoice_id) + payment_account = PaymentAccountModel.find_by_id(invoice.payment_account_id) + + # Clear any existing pending credit links on this invoice + EftService.cancel_payment_action(short_name_id, payment_account.auth_account_id, invoice_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: + return + + eft_credits = EFTCreditModel.get_eft_credits(short_name_id) + for eft_credit in eft_credits: + credit_invoice_link = EFTCreditInvoiceLinkModel( + eft_credit_id=eft_credit.id, + status_code=EFTCreditInvoiceStatus.PENDING.value, + invoice_id=invoice.id, + link_group_id=link_group_id) + + if eft_credit.remaining_amount >= invoice_balance: + # Credit covers the full invoice balance + credit_invoice_link.amount = invoice_balance + credit_invoice_link.save_or_add(auto_save) + eft_credit.remaining_amount -= invoice_balance + eft_credit.save_or_add(auto_save) + break + + # Credit covers partial invoice balance + invoice_balance -= eft_credit.remaining_amount + credit_invoice_link.amount = eft_credit.remaining_amount + credit_invoice_link.save_or_add(auto_save) + eft_credit.remaining_amount = 0 + eft_credit.save_or_add(auto_save) + + @staticmethod + def _return_eft_credit(eft_credit_link: EFTCreditInvoiceLinkModel, + update_status: str = None) -> EFTCreditModel: + """Return EFT Credit Invoice Link amount to EFT Credit.""" + eft_credit = EFTCreditModel.find_by_id(eft_credit_link.eft_credit_id) + eft_credit.remaining_amount += eft_credit_link.amount + + if eft_credit.remaining_amount > eft_credit.amount: + raise BusinessException(Error.EFT_CREDIT_AMOUNT_UNEXPECTED) + + if update_status: + eft_credit_link.status_code = update_status + + return eft_credit + + @staticmethod + def _handle_invoice_refund(invoice: InvoiceModel, + payment_account: PaymentAccount, + cils: List[EFTCreditInvoiceLinkModel]) -> InvoiceStatus: + """Create EFT Short name funds received historical record.""" # 2. No EFT Credit Link - Job needs to reverse invoice in CFS # (Invoice needs to be reversed, receipt doesn't exist.) if not cils: @@ -132,14 +459,15 @@ def process_cfs_refund(self, invoice: InvoiceModel, # 3. EFT Credit Link - PENDING, CANCEL that link - restore balance to EFT credit existing call # (Invoice needs to be reversed, receipt doesn't exist.) for cil in sibling_cils: - EFTShortnames.return_eft_credit(cil, EFTCreditInvoiceStatus.CANCELLED.value) + EftService._return_eft_credit(cil, EFTCreditInvoiceStatus.CANCELLED.value) cil.link_group_id = link_group_id cil.flush() case EFTCreditInvoiceStatus.COMPLETED.value: # 4. EFT Credit Link - COMPLETED # (Invoice needs to be reversed and receipt needs to be reversed.) + # reversal_total = Decimal('0') for cil in sibling_cils: - EFTShortnames.return_eft_credit(cil) + EftService._return_eft_credit(cil) EFTCreditInvoiceLinkModel( eft_credit_id=cil.eft_credit_id, status_code=EFTCreditInvoiceStatus.PENDING_REFUND.value, @@ -147,6 +475,19 @@ def process_cfs_refund(self, invoice: InvoiceModel, receipt_number=cil.receipt_number, invoice_id=invoice.id, link_group_id=link_group_id).flush() + # if corp_type := CorpTypeModel.find_by_code(invoice.corp_type_code): + # if corp_type.has_partner_disbursements: + # reversal_total += cil.amount + + # if reversal_total > 0: + # PartnerDisbursementsModel( + # amount=reversal_total, + # is_reversal=True, + # partner_code=invoice.corp_type_code, + # status_code=DisbursementStatus.WAITING_FOR_JOB.value, + # target_id=invoice.id, + # target_type=EJVLinkType.INVOICE.value + # ).flush() current_balance = EFTCreditModel.get_eft_credit_balance(latest_eft_credit.short_name_id) if existing_balance != current_balance: @@ -165,56 +506,58 @@ def process_cfs_refund(self, invoice: InvoiceModel, return InvoiceStatus.REFUND_REQUESTED.value @staticmethod - def create_invoice_reference(invoice: InvoiceModel, invoice_number: str, - reference_number: str) -> InvoiceReferenceModel: - """Create an invoice reference record.""" - if not (invoice_reference := InvoiceReferenceModel - .find_any_active_reference_by_invoice_number(invoice_number)): - invoice_reference = InvoiceReferenceModel() - - invoice_reference.invoice_id = invoice.id - invoice_reference.invoice_number = invoice_number - invoice_reference.reference_number = reference_number - invoice_reference.status_code = InvoiceReferenceStatus.ACTIVE.value - - return invoice_reference + def process_owing_statements(short_name_id: int, auth_account_id: str, is_new_link: bool = False): + """Process outstanding statement invoices for an EFT Short name.""" + current_app.logger.debug('