diff --git a/jobs/payment-jobs/config.py b/jobs/payment-jobs/config.py index 2d7358714..ca590ddef 100644 --- a/jobs/payment-jobs/config.py +++ b/jobs/payment-jobs/config.py @@ -185,6 +185,8 @@ class _Config(object): # pylint: disable=too-few-public-methods CGI_AP_REMITTANCE_CODE = os.getenv('CGI_AP_REMITTANCE_CODE', '78') BCA_SUPPLIER_NUMBER = os.getenv('BCA_SUPPLIER_NUMBER', '') BCA_SUPPLIER_LOCATION = os.getenv('BCA_SUPPLIER_LOCATION', '') + EFT_AP_DISTRIBUTION = os.getenv('EFT_AP_DISTRIBUTION', '') + EFT_AP_SUPPLIER_LOCATION = os.getenv('EFT_AP_SUPPLIER_LOCATION', '') # FAS Client and secret CFS_FAS_CLIENT_ID = os.getenv('CFS_FAS_CLIENT_ID', '') diff --git a/jobs/payment-jobs/poetry.lock b/jobs/payment-jobs/poetry.lock index 0141b1fe4..e3398d1f4 100644 --- a/jobs/payment-jobs/poetry.lock +++ b/jobs/payment-jobs/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -2021,9 +2021,9 @@ werkzeug = "3.0.3" [package.source] type = "git" -url = "https://github.com/ochiu/sbc-pay.git" -reference = "22982-EFT-Wire" -resolved_reference = "a2fe75dfc54f2531981ddfc95d4aad74c4e15a84" +url = "https://github.com/bcgov/sbc-pay.git" +reference = "feature/22263" +resolved_reference = "86f23886af96d7f361165ae32ff71396d1bf2183" subdirectory = "pay-api" [[package]] @@ -2831,8 +2831,8 @@ develop = false [package.source] type = "git" url = "https://github.com/bcgov/sbc-connect-common.git" -reference = "HEAD" -resolved_reference = "c898988d239dc261b2b186465a1887f15512c102" +reference = "main" +resolved_reference = "43411ed428c4c4b89bea1ac6acdb10077f247d2b" subdirectory = "python/sql-versioning" [[package]] @@ -3174,4 +3174,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "39199fd3bec6c68570595ed9226bf6b79b1e36fe2e93ef3e3b7e1ec981f32e42" +content-hash = "9dea67bbde426d21457ec8325937cdc37b09ad9dae99454fedf751914576f06b" diff --git a/jobs/payment-jobs/pyproject.toml b/jobs/payment-jobs/pyproject.toml index 72b4487a4..2b43b8db2 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/ochiu/sbc-pay.git", branch = "22982-EFT-Wire", subdirectory = "pay-api"} +pay-api = {git = "https://github.com/bcgov/sbc-pay.git", subdirectory = "pay-api", branch = "feature/22263"} flask = "^3.0.2" flask-sqlalchemy = "^3.1.1" sqlalchemy = "^2.0.28" diff --git a/jobs/payment-jobs/tasks/ap_task.py b/jobs/payment-jobs/tasks/ap_task.py index e1f43d9c4..9e0b22e0c 100644 --- a/jobs/payment-jobs/tasks/ap_task.py +++ b/jobs/payment-jobs/tasks/ap_task.py @@ -13,14 +13,18 @@ # limitations under the License. """Task to create AP file for FAS refunds and Disbursement via EFT for non-government orgs without a GL.""" +import time +from datetime import date, datetime, timezone from typing import List -from datetime import date, datetime, timezone -import time from flask import current_app from more_itertools import batched from pay_api.models import CorpType as CorpTypeModel from pay_api.models import DistributionCode as DistributionCodeModel +from pay_api.models import EFTCredit as EFTCreditModel +from pay_api.models import EFTCreditInvoiceLink as EFTCreditInvoiceLinkModel +from pay_api.models import EFTRefund as EFTRefundModel +from pay_api.models import EFTShortnameLinks as EFTShortnameLinksModel from pay_api.models import EjvFile as EjvFileModel from pay_api.models import EjvHeader as EjvHeaderModel from pay_api.models import EjvLink as EjvLinkModel @@ -28,7 +32,9 @@ from pay_api.models import Refund as RefundModel from pay_api.models import RoutingSlip as RoutingSlipModel from pay_api.models import db -from pay_api.utils.enums import DisbursementStatus, EjvFileType, EJVLinkType, RoutingSlipStatus +from pay_api.utils.enums import ( + DisbursementStatus, EFTShortnameRefundStatus, EjvFileType, EJVLinkType, RoutingSlipStatus) + from tasks.common.cgi_ap import CgiAP from tasks.common.dataclasses import APLine from tasks.ejv_partner_distribution_task import EjvPartnerDistributionTask @@ -59,9 +65,63 @@ def create_ap_files(cls): 6. Create AP file and upload to SFTP. 7. After some time, a feedback file will arrive and Payment-reconciliation queue will set disbursement status to COMPLETED or REVERSED (filter out) + 8. Find all EFT refunds with status APPROVED. + 9. Create AP file and upload to SFTP. + 10. After some time, a feedback file will arrive and Payment-reconciliation queue will move these + EFT refunds to REFUNDED. (filter out) """ cls._create_routing_slip_refund_file() cls._create_non_gov_disbursement_file() + cls._create_eft_refund_file() + + @classmethod + def _create_eft_refund_file(cls): + """Create AP file for EFT refunds and upload to CGI.""" + cls.ap_type = EjvFileType.EFT_REFUND + eft_refunds_dao: List[EFTRefundModel] = db.session.query(EFTRefundModel) \ + .join(EFTShortnameLinksModel, EFTRefundModel.short_name_id == EFTShortnameLinksModel.eft_short_name_id) \ + .join(EFTCreditModel, EFTCreditModel.short_name_id == EFTShortnameLinksModel.eft_short_name_id) \ + .join(EFTCreditInvoiceLinkModel, EFTCreditModel.id == EFTCreditInvoiceLinkModel.eft_credit_id) \ + .join(InvoiceModel, EFTCreditInvoiceLinkModel.invoice_id == InvoiceModel.id) \ + .filter(EFTRefundModel.status == EFTShortnameRefundStatus.APPROVED.value) \ + .filter(EFTRefundModel.disbursement_status_code != DisbursementStatus.UPLOADED.value) \ + .filter(EFTRefundModel.refund_amount > 0) \ + .all() + + current_app.logger.info(f'Found {len(eft_refunds_dao)} to refund.') + + for refunds in list(batched(eft_refunds_dao, 250)): + ejv_file_model = EjvFileModel( + file_type=cls.ap_type.value, + file_ref=cls.get_file_name(), + disbursement_status_code=DisbursementStatus.UPLOADED.value + ).flush() + batch_number: str = cls.get_batch_number(ejv_file_model.id) + ap_content: str = cls.get_batch_header(batch_number) + batch_total = 0 + line_count_total = 0 + for eft_refund in refunds: + current_app.logger.info( + f'Creating refund for EFT Refund {eft_refund.id}, Amount {eft_refund.refund_amount}.') + ap_content = f'{ap_content}{cls.get_ap_header( + eft_refund.refund_amount, eft_refund.id, eft_refund.created_on, eft_refund.cas_supplier_number)}' + ap_line = APLine( + total=eft_refund.refund_amount, + invoice_number=eft_refund.id, + line_number=line_count_total + 1) + ap_content = f'{ap_content}{cls.get_ap_invoice_line(ap_line, eft_refund.cas_supplier_number)}' + line_count_total += 2 + if ap_comment := cls.get_eft_ap_comment( + eft_refund.comment, eft_refund.id, eft_refund.short_name_id, eft_refund.cas_supplier_number + ): + ap_content = f'{ap_content}{ap_comment:<40}' + line_count_total += 1 + batch_total += eft_refund.refund_amount + eft_refund.disbursement_status_code = DisbursementStatus.UPLOADED.value + + batch_trailer: str = cls.get_batch_trailer(batch_number, batch_total, control_total=line_count_total) + ap_content = f'{ap_content}{batch_trailer}' + cls._create_file_and_upload(ap_content) @classmethod def _create_routing_slip_refund_file(cls): # pylint:disable=too-many-locals, too-many-statements @@ -96,7 +156,7 @@ def _create_routing_slip_refund_file(cls): # pylint:disable=too-many-locals, to ap_content = f'{ap_content}{cls.get_ap_invoice_line(ap_line)}' ap_content = f'{ap_content}{cls.get_ap_address(refund.details, rs.number)}' total_line_count += 3 - if ap_comment := cls.get_ap_comment(refund.details, rs.number): + if ap_comment := cls.get_rs_ap_comment(refund.details, rs.number): ap_content = f'{ap_content}{ap_comment:<40}' total_line_count += 1 batch_total += rs.refund_amount diff --git a/jobs/payment-jobs/tasks/common/cgi_ap.py b/jobs/payment-jobs/tasks/common/cgi_ap.py index 51f5d576e..0549c5ad3 100644 --- a/jobs/payment-jobs/tasks/common/cgi_ap.py +++ b/jobs/payment-jobs/tasks/common/cgi_ap.py @@ -16,7 +16,7 @@ from datetime import datetime, timezone from flask import current_app -from pay_api.utils.enums import EjvFileType +from pay_api.utils.enums import DisbursementMethod, EjvFileType from pay_api.utils.util import get_fiscal_year from tasks.common.dataclasses import APLine @@ -43,7 +43,7 @@ def get_batch_trailer(cls, batch_number, batch_total, batch_type: str = 'AP', co f'{control_total:0>15}{cls.format_amount(batch_total)}{cls.DELIMITER}{os.linesep}' @classmethod - def get_ap_header(cls, total, invoice_number, invoice_date): + def get_ap_header(cls, total, invoice_number, invoice_date, supplier_number: str = None): """Get AP Invoice Header string.""" invoice_type = 'ST' remit_code = f"{current_app.config.get('CGI_AP_REMITTANCE_CODE'):<4}" @@ -51,17 +51,18 @@ def get_ap_header(cls, total, invoice_number, invoice_date): effective_date = cls._get_date(datetime.now(tz=timezone.utc)) invoice_date = cls._get_date(invoice_date) oracle_invoice_batch_name = cls._get_oracle_invoice_batch_name(invoice_number) - disbursement_method = 'CHQ' if cls.ap_type == EjvFileType.REFUND else 'EFT' + disbursement_method = (DisbursementMethod.CHEQUE.value + if cls.ap_type == EjvFileType.REFUND else DisbursementMethod.EFT.value) term = f'{cls.EMPTY:<50}' if cls.ap_type == EjvFileType.REFUND else f'Immediate{cls.EMPTY:<41}' - ap_header = f'{cls._feeder_number()}APIH{cls.DELIMITER}{cls._supplier_number()}{cls._supplier_location()}' \ - f'{invoice_number:<50}{cls._po_number()}{invoice_type}{invoice_date}GEN {disbursement_method} N' \ - f'{remit_code}{cls.format_amount(total)}{currency}{effective_date}' \ + ap_header = f'{cls._feeder_number()}APIH{cls.DELIMITER}{cls._supplier_number(supplier_number)}' \ + f'{cls._supplier_location()}{invoice_number:<50}{cls._po_number()}{invoice_type}{invoice_date}' \ + f'GEN {disbursement_method} N{remit_code}{cls.format_amount(total)}{currency}{effective_date}' \ f'{term}{cls.EMPTY:<60}{cls.EMPTY:<8}{cls.EMPTY:<8}' \ f'{oracle_invoice_batch_name:<30}{cls.EMPTY:<9}Y{cls.EMPTY:<110}{cls.DELIMITER}{os.linesep}' return ap_header @classmethod - def get_ap_invoice_line(cls, ap_line: APLine): + def get_ap_invoice_line(cls, ap_line: APLine, supplier_number: str = None): """Get AP Invoice Line string.""" commit_line_number = f'{cls.EMPTY:<4}' # Pad Zeros to four digits. EG. 0001 @@ -69,12 +70,12 @@ def get_ap_invoice_line(cls, ap_line: APLine): effective_date = cls._get_date(datetime.now(tz=timezone.utc)) line_code = cls._get_line_code(ap_line) ap_line = \ - f'{cls._feeder_number()}APIL{cls.DELIMITER}{cls._supplier_number()}{cls._supplier_location()}' \ - f'{ap_line.invoice_number:<50}{line_number}{commit_line_number}{cls.format_amount(ap_line.total)}' \ - f'{line_code}{cls._distribution(ap_line.distribution)}{cls.EMPTY:<55}{effective_date}{cls.EMPTY:<10}' \ - f'{cls.EMPTY:<15}{cls.EMPTY:<15}{cls.EMPTY:<15}{cls.EMPTY:<15}{cls.EMPTY:<20}{cls.EMPTY:<4}' \ - f'{cls.EMPTY:<30}{cls.EMPTY:<25}{cls.EMPTY:<30}{cls.EMPTY:<8}{cls.EMPTY:<1}{cls._dist_vendor()}' \ - f'{cls.EMPTY:<110}{cls.DELIMITER}{os.linesep}' + f'{cls._feeder_number()}APIL{cls.DELIMITER}{cls._supplier_number(supplier_number)}' \ + f'{cls._supplier_location()}{ap_line.invoice_number:<50}{line_number}{commit_line_number}' \ + f'{cls.format_amount(ap_line.total)}{line_code}{cls._distribution(ap_line.distribution)}{cls.EMPTY:<55}' \ + f'{effective_date}{cls.EMPTY:<10}{cls.EMPTY:<15}{cls.EMPTY:<15}{cls.EMPTY:<15}{cls.EMPTY:<15}' \ + f'{cls.EMPTY:<20}{cls.EMPTY:<4}{cls.EMPTY:<30}{cls.EMPTY:<25}{cls.EMPTY:<30}{cls.EMPTY:<8}{cls.EMPTY:<1}' \ + f'{cls._dist_vendor(supplier_number)}{cls.EMPTY:<110}{cls.DELIMITER}{os.linesep}' return ap_line @classmethod @@ -108,7 +109,17 @@ def get_ap_address(cls, refund_details, routing_slip_number): return ap_address @classmethod - def get_ap_comment(cls, refund_details, routing_slip_number): + def get_eft_ap_comment(cls, comment, refund_id, short_name_id, supplier_number): + """Get AP Comment Override. EFT only.""" + line_text = '0001' + combined_comment = f'{cls.EMPTY:<1}{short_name_id}{cls.EMPTY:<1}-{cls.EMPTY:<1}{comment}'[:40] + ap_comment = f'{cls._feeder_number()}APIC{cls.DELIMITER}{cls._supplier_number(supplier_number)}' \ + f'{cls._supplier_location()}{refund_id:<50}{line_text}{combined_comment}' \ + f'{cls.DELIMITER}{os.linesep}' + return ap_comment + + @classmethod + def get_rs_ap_comment(cls, refund_details, routing_slip_number): """Get AP Comment Override. Routing slip only.""" if not (cheque_advice := refund_details.get('chequeAdvice', '')): return None @@ -120,31 +131,43 @@ def get_ap_comment(cls, refund_details, routing_slip_number): return ap_comment @classmethod - def _supplier_number(cls): + def _supplier_number(cls, supplier_number: str = None): """Return vendor number.""" - if cls.ap_type == EjvFileType.NON_GOV_DISBURSEMENT: - return f"{current_app.config.get('BCA_SUPPLIER_NUMBER'):<9}" - if cls.ap_type == EjvFileType.REFUND: - return f"{current_app.config.get('CGI_AP_SUPPLIER_NUMBER'):<9}" - raise RuntimeError('ap_type not selected.') + if supplier_number: + return f'{supplier_number:<9}' + match cls.ap_type: + case EjvFileType.NON_GOV_DISBURSEMENT: + return f"{current_app.config.get('BCA_SUPPLIER_NUMBER'):<9}" + case EjvFileType.REFUND: + return f"{current_app.config.get('CGI_AP_SUPPLIER_NUMBER'):<9}" + case _: + raise RuntimeError('ap_type not selected.') @classmethod - def _dist_vendor(cls): + def _dist_vendor(cls, supplier_number: str = None): """Return distribution vendor number.""" - if cls.ap_type == EjvFileType.NON_GOV_DISBURSEMENT: - return f"{current_app.config.get('BCA_SUPPLIER_NUMBER'):<30}" - if cls.ap_type == EjvFileType.REFUND: - return f"{current_app.config.get('CGI_AP_SUPPLIER_NUMBER'):<30}" - raise RuntimeError('ap_type not selected.') + if supplier_number: + return f'{supplier_number:<30}' + match cls.ap_type: + case EjvFileType.NON_GOV_DISBURSEMENT: + return f"{current_app.config.get('BCA_SUPPLIER_NUMBER'):<30}" + case EjvFileType.REFUND: + return f"{current_app.config.get('CGI_AP_SUPPLIER_NUMBER'):<30}" + case _: + raise RuntimeError('ap_type not selected.') @classmethod def _supplier_location(cls): """Return location.""" - if cls.ap_type == EjvFileType.NON_GOV_DISBURSEMENT: - return f"{current_app.config.get('BCA_SUPPLIER_LOCATION'):<3}" - if cls.ap_type == EjvFileType.REFUND: - return f"{current_app.config.get('CGI_AP_SUPPLIER_LOCATION'):<3}" - raise RuntimeError('ap_type not selected.') + match cls.ap_type: + case EjvFileType.NON_GOV_DISBURSEMENT: + return f"{current_app.config.get('BCA_SUPPLIER_LOCATION'):<3}" + case EjvFileType.REFUND: + return f"{current_app.config.get('CGI_AP_SUPPLIER_LOCATION'):<3}" + case EjvFileType.EFT_REFUND: + return f"{current_app.config.get('EFT_AP_SUPPLIER_LOCATION'):<3}" + case _: + raise RuntimeError('ap_type not selected.') @classmethod def _po_number(cls): @@ -159,28 +182,36 @@ def _get_date(cls, date): @classmethod def _distribution(cls, distribution_code: str = None): """Return Distribution Code string.""" - if cls.ap_type == EjvFileType.NON_GOV_DISBURSEMENT: - return f'{distribution_code}0000000000{cls.EMPTY:<16}' - if cls.ap_type == EjvFileType.REFUND: - return f"{current_app.config.get('CGI_AP_DISTRIBUTION')}{cls.EMPTY:<16}" - raise RuntimeError('ap_type not selected.') + match cls.ap_type: + case EjvFileType.NON_GOV_DISBURSEMENT: + return f'{distribution_code}0000000000{cls.EMPTY:<16}' + case EjvFileType.REFUND: + return f"{current_app.config.get('CGI_AP_DISTRIBUTION')}{cls.EMPTY:<16}" + case EjvFileType.EFT_REFUND: + return f"{current_app.config.get('EFT_AP_DISTRIBUTION')}{cls.EMPTY:<16}" + case _: + raise RuntimeError('ap_type not selected.') @classmethod def _get_oracle_invoice_batch_name(cls, invoice_number): """Return Oracle Invoice Batch Name.""" - if cls.ap_type == EjvFileType.NON_GOV_DISBURSEMENT: - return f'{invoice_number}'[:30] - if cls.ap_type == EjvFileType.REFUND: - return f'REFUND_FAS_RS_{invoice_number}'[:30] - raise RuntimeError('ap_type not selected.') + match cls.ap_type: + case EjvFileType.NON_GOV_DISBURSEMENT: + return f'{invoice_number}'[:30] + case EjvFileType.REFUND: + return f'REFUND_FAS_RS_{invoice_number}'[:30] + case EjvFileType.EFT_REFUND: + return f'REFUND_EFT_{invoice_number}'[:30] + case _: + raise RuntimeError('ap_type not selected.') @classmethod def _get_line_code(cls, ap_line: APLine): - # Routing slip refunds always DEBIT the internal GL and mails out cheques. - if cls.ap_type == EjvFileType.REFUND: - return 'D' - if cls.ap_type == EjvFileType.NON_GOV_DISBURSEMENT: - if ap_line.is_reversal: - return 'C' - return 'D' - raise RuntimeError('ap_type not selected.') + """Get line code.""" + match cls.ap_type: + case EjvFileType.REFUND | EjvFileType.EFT_REFUND: + return 'D' + case EjvFileType.NON_GOV_DISBURSEMENT: + return 'C' if ap_line.is_reversal else 'D' + case _: + raise RuntimeError('ap_type not selected.') diff --git a/jobs/payment-jobs/tasks/common/cgi_ejv.py b/jobs/payment-jobs/tasks/common/cgi_ejv.py index b726af290..90700bef8 100644 --- a/jobs/payment-jobs/tasks/common/cgi_ejv.py +++ b/jobs/payment-jobs/tasks/common/cgi_ejv.py @@ -135,7 +135,7 @@ def get_journal_name(cls, ejv_header_id: int): return f'{cls.ministry()}{ejv_header_id:0>8}' @classmethod - def get_batch_number(cls, ejv_file_id: int): + def get_batch_number(cls, ejv_file_id: int) -> str: """Return batch number.""" return f'{ejv_file_id:0>9}' diff --git a/jobs/payment-jobs/tests/jobs/factory.py b/jobs/payment-jobs/tests/jobs/factory.py index ab966f3fb..d4c818d34 100644 --- a/jobs/payment-jobs/tests/jobs/factory.py +++ b/jobs/payment-jobs/tests/jobs/factory.py @@ -21,12 +21,12 @@ from random import randrange from pay_api.models import ( - CfsAccount, DistributionCode, DistributionCodeLink, EFTCredit, EFTCreditInvoiceLink, EFTFile, EFTShortnameLinks, - EFTShortnames, EFTShortnamesHistorical, EFTTransaction, Invoice, InvoiceReference, Payment, PaymentAccount, - PaymentLineItem, Receipt, Refund, RefundsPartial, RoutingSlip, Statement, StatementInvoices, StatementRecipients, - StatementSettings) + CfsAccount, DistributionCode, DistributionCodeLink, EFTCredit, EFTCreditInvoiceLink, EFTFile, EFTRefund, + EFTShortnameLinks, EFTShortnames, EFTShortnamesHistorical, EFTTransaction, Invoice, InvoiceReference, Payment, + PaymentAccount, PaymentLineItem, Receipt, Refund, RefundsPartial, RoutingSlip, Statement, StatementInvoices, + StatementRecipients, StatementSettings) from pay_api.utils.enums import ( - CfsAccountStatus, EFTHistoricalTypes, EFTProcessStatus, EFTShortnameStatus, EFTShortnameType, + CfsAccountStatus, DisbursementStatus, EFTHistoricalTypes, EFTProcessStatus, EFTShortnameStatus, EFTShortnameType, InvoiceReferenceStatus, InvoiceStatus, LineItemStatus, PaymentMethod, PaymentStatus, PaymentSystem, RoutingSlipStatus) @@ -348,6 +348,29 @@ def factory_create_eft_shortname_historical(payment_account_id=1, related_group_ return eft_historical +def factory_create_eft_refund( + cas_supplier_number: str = '1234', + comment: str = 'Test Comment', + refund_amount: float = 100.0, + refund_email: str = '', + short_name_id: int = 1, + status: str = InvoiceStatus.APPROVED.value, + disbursement_status_code: str = DisbursementStatus.ACKNOWLEDGED.value +): + """Return Factory.""" + eft_refund = EFTRefund( + cas_supplier_number=cas_supplier_number, + comment=comment, + disbursement_status_code=disbursement_status_code, + refund_amount=refund_amount, + refund_email=refund_email, + short_name_id=short_name_id, + status=status, + created_on=datetime.now(tz=timezone.utc) + ) + return eft_refund + + def factory_create_account(auth_account_id: str = '1234', payment_method_code: str = PaymentMethod.DIRECT_PAY.value, status: str = CfsAccountStatus.PENDING.value, statement_notification_enabled: bool = True): """Return payment account model.""" diff --git a/jobs/payment-jobs/tests/jobs/test_ap_task.py b/jobs/payment-jobs/tests/jobs/test_ap_task.py index 5ec593540..adc79537b 100644 --- a/jobs/payment-jobs/tests/jobs/test_ap_task.py +++ b/jobs/payment-jobs/tests/jobs/test_ap_task.py @@ -20,14 +20,59 @@ from pay_api.models import FeeSchedule as FeeScheduleModel from pay_api.models import RoutingSlip -from pay_api.utils.enums import CfsAccountStatus, DisbursementStatus, InvoiceStatus, RoutingSlipStatus +from pay_api.utils.enums import ( + CfsAccountStatus, DisbursementStatus, EFTShortnameRefundStatus, InvoiceStatus, PaymentMethod, RoutingSlipStatus) from tasks.ap_task import ApTask + from .factory import ( - factory_create_pad_account, factory_invoice, factory_payment_line_item, factory_refund, + factory_create_eft_account, factory_create_eft_credit, factory_create_eft_credit_invoice_link, + factory_create_eft_file, factory_create_eft_refund, factory_create_eft_shortname, factory_create_eft_transaction, + factory_create_pad_account, factory_eft_shortname_link, factory_invoice, factory_payment_line_item, factory_refund, factory_routing_slip_account) +def test_eft_refunds(session, monkeypatch): + """Test EFT AP refund job. + + Steps: + 1) Create an invoice with refund and status REFUNDED + 2) Run the job and assert status + """ + account = factory_create_eft_account( + auth_account_id='1', + status=CfsAccountStatus.ACTIVE.value + ) + invoice = factory_invoice( + payment_account=account, + payment_method_code=PaymentMethod.EFT.value, + status_code=InvoiceStatus.PAID.value, + total=100, + ) + short_name = factory_create_eft_shortname('SHORTNAMETEST') + eft_refund = factory_create_eft_refund( + disbursement_status_code=DisbursementStatus.ACKNOWLEDGED.value, + refund_amount=100, + refund_email='test@test.com', + short_name_id=short_name.id, + status=EFTShortnameRefundStatus.APPROVED.value + ) + eft_refund.save() + eft_file = factory_create_eft_file() + eft_transaction = factory_create_eft_transaction(file_id=eft_file.id) + eft_credit = factory_create_eft_credit( + short_name_id=short_name.id, + eft_transaction_id=eft_transaction.id, + eft_file_id=eft_file.id + ) + factory_create_eft_credit_invoice_link(invoice_id=invoice.id, eft_credit_id=eft_credit.id) + factory_eft_shortname_link(short_name_id=short_name.id) + + with patch('pysftp.Connection.put') as mock_upload: + ApTask.create_ap_files() + mock_upload.assert_called() + + def test_routing_slip_refunds(session, monkeypatch): """Test Routing slip AP refund job. diff --git a/jobs/payment-jobs/utils/enums.py b/jobs/payment-jobs/utils/enums.py index a50a5209d..e81012484 100644 --- a/jobs/payment-jobs/utils/enums.py +++ b/jobs/payment-jobs/utils/enums.py @@ -3,7 +3,7 @@ class StatementNotificationAction(Enum): """Enum for the action to take for a statement.""" - + DUE = 'due' OVERDUE = 'overdue' REMINDER = 'reminder' diff --git a/pay-api/migrations/versions/2024_10_01_9e5c82dfe9c7_adding_disbursement_status_code_column_.py b/pay-api/migrations/versions/2024_10_01_9e5c82dfe9c7_adding_disbursement_status_code_column_.py new file mode 100644 index 000000000..62962c42e --- /dev/null +++ b/pay-api/migrations/versions/2024_10_01_9e5c82dfe9c7_adding_disbursement_status_code_column_.py @@ -0,0 +1,32 @@ +"""Adding disbursement_status_code column to eft_refunds + +Revision ID: 9e5c82dfe9c7 +Revises: 87a09ba91c0d +Create Date: 2024-10-01 08:19:52.425041 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +# Note you may see foreign keys with distribution_codes_history +# For disbursement_distribution_code_id, service_fee_distribution_code_id +# Please ignore those lines and don't include in migration. + +revision = '9e5c82dfe9c7' +down_revision = '87a09ba91c0d' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table('eft_refunds', schema=None) as batch_op: + batch_op.add_column(sa.Column('disbursement_status_code', sa.String(length=20), nullable=True)) + batch_op.create_foreign_key(None, 'disbursement_status_codes', ['disbursement_status_code'], ['code']) + + +def downgrade(): + with op.batch_alter_table('eft_refunds', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_column('disbursement_status_code') diff --git a/pay-api/src/pay_api/models/eft_refund.py b/pay-api/src/pay_api/models/eft_refund.py index f94ac1993..0855a0bcc 100644 --- a/pay-api/src/pay_api/models/eft_refund.py +++ b/pay-api/src/pay_api/models/eft_refund.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """Model to handle EFT REFUNDS, this is picked up by the AP job to mail out.""" +from datetime import datetime, timezone from typing import List from sqlalchemy import ForeignKey @@ -42,6 +43,7 @@ class EFTRefund(Audit): 'created_by', 'created_name' 'created_on', + 'disbursement_status_code', 'decline_reason', 'id', 'refund_amount', @@ -58,6 +60,8 @@ class EFTRefund(Audit): comment = db.Column(db.String(), nullable=False) decline_reason = db.Column(db.String(), nullable=True) created_by = db.Column('created_by', db.String(100), nullable=True) + created_on = db.Column('created_on', db.DateTime, nullable=False, default=lambda: datetime.now(tz=timezone.utc)) + disbursement_status_code = db.Column(db.String(20), ForeignKey('disbursement_status_codes.code'), nullable=True) id = db.Column(db.Integer, primary_key=True, autoincrement=True) refund_amount = db.Column(db.Numeric(), nullable=False) refund_email = db.Column(db.String(100), nullable=False)