diff --git a/jobs/payment-jobs/poetry.lock b/jobs/payment-jobs/poetry.lock index e30037d07..ae4059061 100644 --- a/jobs/payment-jobs/poetry.lock +++ b/jobs/payment-jobs/poetry.lock @@ -2026,8 +2026,8 @@ werkzeug = "3.0.1" [package.source] type = "git" url = "https://github.com/seeker25/sbc-pay.git" -reference = "22992" -resolved_reference = "4668d2b1c7a9653d4d221f489cc4163193b94e86" +reference = "22655" +resolved_reference = "55828250fd4ed135ec7f4d2e3034bb91f5540454" subdirectory = "pay-api" [[package]] @@ -3169,4 +3169,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "1ed8a59292cc566377daa840509a7f0893b1638ba863327b2718207e4765ba77" +content-hash = "d116949d2f4a0b53bba4c39f7ae7fe363e0499ace93e77cba794e781cc700489" diff --git a/jobs/payment-jobs/pyproject.toml b/jobs/payment-jobs/pyproject.toml index 2470d32c9..0dbc6834d 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 = "22992", subdirectory = "pay-api"} +pay-api = {git = "https://github.com/seeker25/sbc-pay.git", branch = "22655", subdirectory = "pay-api"} gunicorn = "^21.2.0" flask = "^3.0.2" flask-sqlalchemy = "^3.1.1" diff --git a/jobs/payment-jobs/tasks/eft_task.py b/jobs/payment-jobs/tasks/eft_task.py index a131f3a4a..f58cb3e78 100644 --- a/jobs/payment-jobs/tasks/eft_task.py +++ b/jobs/payment-jobs/tasks/eft_task.py @@ -29,7 +29,6 @@ from pay_api.services.cfs_service import CFSService from pay_api.services.eft_service import EftService from pay_api.services.invoice import Invoice as InvoiceService -from pay_api.utils.constants import CFS_ADJ_ACTIVITY_NAME from pay_api.utils.enums import ( CfsAccountStatus, DisbursementStatus, EFTCreditInvoiceStatus, InvoiceReferenceStatus, InvoiceStatus, PaymentMethod, PaymentStatus, PaymentSystem, ReverseOperation) @@ -116,16 +115,15 @@ class EFTCILRollup: @classmethod def link_electronic_funds_transfers_cfs(cls) -> dict: """Replicate linked EFT's as receipts inside of CFS and mark invoices as paid.""" - target_status = EFTCreditInvoiceStatus.PENDING.value - credit_invoice_links = cls.get_eft_credit_invoice_links_by_status(target_status) + credit_invoice_links = cls.get_eft_credit_invoice_links_by_status(EFTCreditInvoiceStatus.PENDING.value) cls.history_group_ids = set() for invoice, cfs_account, cil_rollup in credit_invoice_links: try: current_app.logger.info(f'PayAccount: {invoice.payment_account_id} Id: {cil_rollup.id} -' f' Invoice Id: {invoice.id} - Amount: {cil_rollup.rollup_amount}') - receipt_number = f'EFTCIL{cil_rollup.id}' if invoice.invoice_status_code == InvoiceStatus.OVERDUE.value: cls.overdue_account_ids[invoice.payment_account_id] = cfs_account.payment_account + receipt_number = f'EFTCIL{cil_rollup.id}' cls._create_receipt_and_invoice(cfs_account, cil_rollup, invoice, receipt_number) cls._update_cil_and_shortname_history(cil_rollup, receipt_number=receipt_number) db.session.commit() @@ -181,10 +179,13 @@ def handle_unlinked_refund_requested_invoices(cls): for invoice in invoices: cfs_account = CfsAccountModel.find_effective_by_payment_method(invoice.payment_account_id, PaymentMethod.EFT.value) + if not cfs_account: + current_app.logger.error(f'No EFT CFS Account found for pay account id={invoice.payment_account_id}') + continue invoice_reference = InvoiceReferenceModel.find_by_invoice_id_and_status( invoice.id, InvoiceReferenceStatus.ACTIVE.value) try: - cls._handle_invoice_refund(cfs_account, invoice, invoice_reference) + cls._handle_invoice_refund(invoice, invoice_reference) db.session.commit() except Exception as e: # NOQA # pylint: disable=broad-except capture_message( @@ -248,8 +249,29 @@ def _create_receipt_and_invoice(cls, if not (invoice_reference := InvoiceReferenceModel.find_by_invoice_id_and_status( cil_rollup.invoice_id, InvoiceReferenceStatus.ACTIVE.value )): - raise Exception(f'Active Invoice reference not ' # pylint: disable=broad-exception-raised - f'found for invoice id: {invoice.id}') + raise LookupError(f'Active Invoice reference not ' + f'found for invoice id: {invoice.id}') + if invoice_reference.is_consolidated: + original_invoice_reference = InvoiceReferenceModel.find_by_invoice_id_and_status( + cil_rollup.invoice_id, InvoiceReferenceStatus.CANCELLED.value, exclude_consolidated=True + ) + if not original_invoice_reference: + raise LookupError(f'Non consolidated cancelled invoice reference not ' + f'found for invoice id: {invoice.id}') + invoice_response = CFSService.get_invoice(cfs_account=cfs_account, + inv_number=original_invoice_reference.invoice_number) + cfs_total = Decimal(invoice_response.get('total', '0')) + invoice_total_matches = cfs_total == invoice.total + if not invoice_total_matches: + raise ValueError(f'SBC-PAY Invoice total {invoice.total} does not match CFS total {cfs_total}') + # Note we do the opposite of this in payment_account. + current_app.logger.info(f'Consolidated invoice found, reversing consolidated ' + f'invoice {invoice_reference.invoice_number}.') + CFSService.reverse_invoice(invoice_reference.invoice_number) + invoice_reference.status_code = InvoiceReferenceStatus.CANCELLED.value + invoice_reference.flush() + invoice_reference = original_invoice_reference + invoice_reference.status_code = InvoiceReferenceStatus.COMPLETED.value invoice_reference.flush() # Note: Not creating the entire EFT as a receipt because it can be mapped to multiple CFS accounts. @@ -284,7 +306,7 @@ def _create_receipt_and_invoice(cls, def _rollback_receipt_and_invoice(cls, cfs_account: CfsAccountModel, invoice: InvoiceModel, receipt_number: str, - cil_status_code): + cil_status_code: str): """Rollback receipt in CFS and reset invoice status.""" invoice_reference_requirement = { EFTCreditInvoiceStatus.PENDING_REFUND.value: InvoiceReferenceStatus.COMPLETED.value, @@ -294,14 +316,16 @@ def _rollback_receipt_and_invoice(cls, cfs_account: CfsAccountModel, invoice_reference = InvoiceReferenceModel.find_by_invoice_id_and_status( invoice.id, invoice_reference_status ) + if invoice_reference and invoice_reference.is_consolidated: + raise ValueError(f'Cannot reverse a consolidated invoice {invoice_reference.invoice_number}') if cil_status_code != EFTCreditInvoiceStatus.CANCELLED.value and not invoice_reference: - raise Exception(f'{invoice_reference_status} invoice reference ' # pylint: disable=broad-exception-raised - f'not found for invoice id: {invoice.id} - {invoice.invoice_status_code}') + raise LookupError(f'{invoice_reference_status} invoice reference ' + f'not found for invoice id: {invoice.id} - {invoice.invoice_status_code}') is_invoice_refund = invoice.invoice_status_code == InvoiceStatus.REFUND_REQUESTED.value is_reversal = not is_invoice_refund CFSService.reverse_rs_receipt_in_cfs(cfs_account, receipt_number, ReverseOperation.VOID.value) if is_invoice_refund: - cls._handle_invoice_refund(cfs_account, invoice, invoice_reference) + cls._handle_invoice_refund(invoice, invoice_reference) else: invoice_reference.status_code = InvoiceReferenceStatus.ACTIVE.value invoice.paid = 0 @@ -317,28 +341,16 @@ def _rollback_receipt_and_invoice(cls, cfs_account: CfsAccountModel, @classmethod def _handle_invoice_refund(cls, - cfs_account: CfsAccountModel, invoice: InvoiceModel, invoice_reference: InvoiceReferenceModel): """Handle invoice refunds adjustment on a non-rolled up invoice.""" if invoice_reference: - adjustment_lines = cls._build_reversal_adjustment_lines(invoice) - CFSService.adjust_invoice(cfs_account, invoice_reference.invoice_number, adjustment_lines=adjustment_lines) + if invoice_reference.is_consolidated: + raise ValueError(f'Cannot reverse a consolidated invoice: {invoice_reference.invoice_number}') + CFSService.reverse_invoice(invoice_reference.invoice_number) invoice_reference.status_code = InvoiceReferenceStatus.CANCELLED.value invoice_reference.flush() invoice.invoice_status_code = InvoiceStatus.REFUNDED.value invoice.refund_date = datetime.now(tz=timezone.utc) invoice.refund = invoice.total invoice.flush() - - @classmethod - def _build_reversal_adjustment_lines(cls, invoice: InvoiceModel) -> list: - """Build the adjustment lines for the invoice.""" - return [ - { - 'line_number': line['line_number'], - 'adjustment_amount': line['unit_price'], - 'activity_name': CFS_ADJ_ACTIVITY_NAME - } - for line in CFSService.build_lines(invoice.payment_line_items, negate=True) - ] diff --git a/jobs/payment-jobs/tests/jobs/factory.py b/jobs/payment-jobs/tests/jobs/factory.py index 40368ff17..bcb553952 100644 --- a/jobs/payment-jobs/tests/jobs/factory.py +++ b/jobs/payment-jobs/tests/jobs/factory.py @@ -164,11 +164,13 @@ def factory_payment_line_item(invoice_id: str, fee_schedule_id: int, filing_fees def factory_invoice_reference(invoice_id: int, invoice_number: str = '10021', - status_code=InvoiceReferenceStatus.ACTIVE.value): + status_code=InvoiceReferenceStatus.ACTIVE.value, + is_consolidated=False): """Return Factory.""" return InvoiceReference(invoice_id=invoice_id, status_code=status_code, - invoice_number=invoice_number).save() + invoice_number=invoice_number, + is_consolidated=is_consolidated).save() def factory_create_online_banking_account(auth_account_id='1234', status=CfsAccountStatus.PENDING.value, diff --git a/jobs/payment-jobs/tests/jobs/test_eft_task.py b/jobs/payment-jobs/tests/jobs/test_eft_task.py index 25c334d9a..98a6711a3 100644 --- a/jobs/payment-jobs/tests/jobs/test_eft_task.py +++ b/jobs/payment-jobs/tests/jobs/test_eft_task.py @@ -143,7 +143,9 @@ def test_eft_credit_invoice_links_by_status(session, test_name, payment_method, assert len(results) == 1 -def test_link_electronic_funds_transfers(session): +@pytest.mark.parametrize('test_name', ('happy_path', 'consolidated_happy', 'consolidated_mismatch', + 'normal_invoice_missing')) +def test_link_electronic_funds_transfers(session, test_name): """Test link electronic funds transfers.""" auth_account_id, eft_file, short_name_id, eft_transaction_id = setup_eft_credit_invoice_links_test() payment_account = factory_create_eft_account(auth_account_id=auth_account_id, status=CfsAccountStatus.ACTIVE.value) @@ -169,13 +171,48 @@ def test_link_electronic_funds_transfers(session): cfs_account = CfsAccountModel.find_effective_by_payment_method( payment_account.id, PaymentMethod.EFT.value) + return_value = {} + original_invoice_reference = None - with patch('pay_api.services.CFSService.create_cfs_receipt') as mock_create_cfs: - EFTTask.link_electronic_funds_transfers_cfs() - mock_create_cfs.assert_called() + match test_name: + case 'consolidated_happy' | 'consolidated_mismatch': + invoice_reference.is_consolidated = True + invoice_reference.save() + original_invoice_reference = factory_invoice_reference(invoice_id=invoice.id, + is_consolidated=False, + status_code=InvoiceReferenceStatus.CANCELLED.value) \ + .save() + return_value = {'total': 10.00} + if test_name == 'consolidated_mismatch': + return_value = {'total': 10.01} + case 'normal_invoice_missing': + invoice_reference.is_consolidated = True + invoice_reference.save() + case _: + pass + + if test_name in ['consolidated_mismatch', 'normal_invoice_missing']: + with patch('pay_api.services.CFSService.get_invoice', return_value=return_value) as mock_get_invoice: + EFTTask.link_electronic_funds_transfers_cfs() + # No change, the amount didn't match or normal invoice was missing. + assert invoice_reference.status_code == InvoiceReferenceStatus.ACTIVE.value + return + + with patch('pay_api.services.CFSService.reverse_invoice') as mock_reverse_invoice: + with patch('pay_api.services.CFSService.create_cfs_receipt') as mock_create_receipt: + with patch('pay_api.services.CFSService.get_invoice', return_value=return_value) as mock_get_invoice: + EFTTask.link_electronic_funds_transfers_cfs() + if test_name == 'consolidated_happy': + mock_reverse_invoice.assert_called() + mock_get_invoice.assert_called() + mock_create_receipt.assert_called() assert cfs_account.status == CfsAccountStatus.ACTIVE.value - assert invoice_reference.status_code == InvoiceReferenceStatus.COMPLETED.value + if test_name == 'consolidated_happy': + assert invoice_reference.status_code == InvoiceReferenceStatus.CANCELLED.value + assert original_invoice_reference.status_code == InvoiceReferenceStatus.COMPLETED.value + else: + assert invoice_reference.status_code == InvoiceReferenceStatus.COMPLETED.value receipt = ReceiptModel.find_all_receipts_for_invoice(invoice.id)[0] assert receipt assert receipt.receipt_amount == credit_invoice_link.amount + credit_invoice_link2.amount @@ -259,7 +296,7 @@ def test_reverse_electronic_funds_transfers(session): session.commit() with patch('pay_api.services.CFSService.reverse_rs_receipt_in_cfs') as mock_reverse: - with patch('pay_api.services.CFSService.adjust_invoice') as mock_invoice: + with patch('pay_api.services.CFSService.reverse_invoice') as mock_invoice: EFTTask.reverse_electronic_funds_transfers_cfs() mock_invoice.assert_called() mock_reverse.assert_called() @@ -328,7 +365,7 @@ def test_handle_unlinked_refund_requested_invoices(session): invoice_ref_2 = factory_invoice_reference(invoice_id=invoice_2.id).save() invoice_3 = factory_invoice(payment_account=payment_account, status_code=InvoiceStatus.REFUND_REQUESTED.value, payment_method_code=PaymentMethod.EFT.value, total=10).save() - with patch('pay_api.services.CFSService.adjust_invoice') as mock_invoice: + with patch('pay_api.services.CFSService.reverse_invoice') as mock_invoice: EFTTask.handle_unlinked_refund_requested_invoices() mock_invoice.assert_called() # Has CIL so it's excluded @@ -340,3 +377,21 @@ def test_handle_unlinked_refund_requested_invoices(session): assert invoice_ref_2.status_code == InvoiceReferenceStatus.CANCELLED.value # Has no invoice reference, should still move to REFUNDED assert invoice_3.invoice_status_code == InvoiceStatus.REFUNDED.value + + +def test_rollback_consolidated_invoice(): + """Ensure we can't rollback a consolidated invoice.""" + payment_account = factory_create_eft_account(status=CfsAccountStatus.ACTIVE.value) + invoice_1 = factory_invoice(payment_account=payment_account).save() + invoice_reference = factory_invoice_reference(invoice_id=invoice_1.id, + status_code=InvoiceReferenceStatus.COMPLETED.value, + is_consolidated=True).save() + with pytest.raises(Exception) as excinfo: + EFTTask._rollback_receipt_and_invoice(None, # pylint: disable=protected-access + invoice_1, + None, + cil_status_code=EFTCreditInvoiceStatus.PENDING_REFUND.value) + assert 'Cannot reverse a consolidated invoice' in excinfo.value.args + with pytest.raises(Exception) as excinfo: + EFTTask._handle_invoice_refund(None, invoice_reference) # pylint: disable=protected-access + assert 'Cannot reverse a consolidated invoice' in excinfo.value.args diff --git a/pay-api/migrations/versions/2024_08_29_2097573390f1_.py b/pay-api/migrations/versions/2024_08_29_2097573390f1_.py new file mode 100644 index 000000000..7b1159e1a --- /dev/null +++ b/pay-api/migrations/versions/2024_08_29_2097573390f1_.py @@ -0,0 +1,35 @@ +"""Add is_consolidated and created_on columns to invoice_references + +Revision ID: 2097573390f1 +Revises: fc32e7db4493 +Create Date: 2024-08-29 12:01:51.061253 + +""" +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 = '2097573390f1' +down_revision = 'fc32e7db4493' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table('invoice_references', schema=None) as batch_op: + batch_op.add_column(sa.Column('created_on', sa.DateTime(), nullable=True)) + batch_op.add_column(sa.Column('is_consolidated', sa.Boolean(), nullable=False)) + batch_op.create_index(batch_op.f('ix_invoice_references_is_consolidated'), ['is_consolidated'], unique=False) + op.execute("update invoice_references set is_consolidated = 't' where invoice_number like '%-C'") + + +def downgrade(): + with op.batch_alter_table('invoice_references', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_invoice_references_is_consolidated')) + batch_op.drop_column('is_consolidated') + batch_op.drop_column('created_on') diff --git a/pay-api/src/pay_api/models/invoice.py b/pay-api/src/pay_api/models/invoice.py index 15c5bb705..5f0663799 100644 --- a/pay-api/src/pay_api/models/invoice.py +++ b/pay-api/src/pay_api/models/invoice.py @@ -158,10 +158,11 @@ def find_by_business_identifier(cls, business_identifier: str): return cls.query.filter_by(business_identifier=business_identifier).all() @classmethod - def find_invoices_by_status_for_account(cls, pay_account_id: int, invoice_statuses: List[str]): + def find_invoices_by_status_for_account(cls, pay_account_id: int, invoice_statuses: List[str]) -> List[Invoice]: """Return invoices by status for an account.""" - query = cls.query.filter_by(payment_account_id=pay_account_id). \ - filter(Invoice.invoice_status_code.in_(invoice_statuses)) + query = cls.query.filter_by(payment_account_id=pay_account_id) \ + .filter(Invoice.invoice_status_code.in_(invoice_statuses)) \ + .order_by(Invoice.id) return query.all() diff --git a/pay-api/src/pay_api/models/invoice_reference.py b/pay-api/src/pay_api/models/invoice_reference.py index 369205b5d..9449991d8 100644 --- a/pay-api/src/pay_api/models/invoice_reference.py +++ b/pay-api/src/pay_api/models/invoice_reference.py @@ -13,6 +13,8 @@ # limitations under the License. """Model to handle invoice references from third party systems.""" from __future__ import annotations +from datetime import datetime, timezone +from typing import List from marshmallow import fields from sqlalchemy import ForeignKey @@ -41,8 +43,10 @@ class InvoiceReference(BaseModel): # pylint: disable=too-many-instance-attribut __mapper_args__ = { 'include_properties': [ 'id', + 'created_on', 'invoice_id', 'invoice_number', + 'is_consolidated', 'reference_number', 'status_code' ] @@ -50,6 +54,8 @@ class InvoiceReference(BaseModel): # pylint: disable=too-many-instance-attribut id = db.Column(db.Integer, primary_key=True, autoincrement=True) invoice_id = db.Column(db.Integer, ForeignKey('invoices.id'), nullable=False, index=True) + created_on = db.Column(db.DateTime, nullable=True, default=lambda: datetime.now(tz=timezone.utc)) + is_consolidated = db.Column(db.Boolean, nullable=False, default=False, index=True) invoice_number = db.Column(db.String(50), nullable=True, index=True) reference_number = db.Column(db.String(50), nullable=True) @@ -57,9 +63,15 @@ class InvoiceReference(BaseModel): # pylint: disable=too-many-instance-attribut 'invoice_reference_status_codes.code'), nullable=False, index=True) @classmethod - def find_by_invoice_id_and_status(cls, invoice_id: int, status_code: str) -> InvoiceReference: - """Return active Invoice Reference by invoice id.""" - return cls.query.filter_by(invoice_id=invoice_id).filter_by(status_code=status_code).one_or_none() + def find_by_invoice_id_and_status(cls, invoice_id: int, status_code: str, exclude_consolidated=False) \ + -> InvoiceReference: + """Return Invoice Reference by invoice id by status_code.""" + query = cls.query.filter_by(invoice_id=invoice_id).filter_by(status_code=status_code) + if exclude_consolidated: + query = query.filter(InvoiceReference.is_consolidated.is_(False)) + if status_code == InvoiceReferenceStatus.CANCELLED.value: + return query.order_by(InvoiceReference.id.desc()).first() + return query.one_or_none() @classmethod def find_any_active_reference_by_invoice_number(cls, invoice_number: str) -> InvoiceReference: @@ -67,6 +79,23 @@ def find_any_active_reference_by_invoice_number(cls, invoice_number: str) -> Inv return cls.query.filter_by(invoice_number=invoice_number) \ .filter_by(status_code=InvoiceReferenceStatus.ACTIVE.value).first() + @classmethod + def find_non_consolidated_invoice_numbers(cls, invoice_number: str) -> List[str]: + """Find the original invoice numbers that are not consolidated.""" + consolidated_invoice_references = db.session.query(InvoiceReference.invoice_id) \ + .filter(InvoiceReference.invoice_number == invoice_number) \ + .filter(InvoiceReference.is_consolidated.is_(True)) \ + .filter(InvoiceReference.status_code == InvoiceReferenceStatus.COMPLETED.value) \ + .distinct(InvoiceReference.invoice_id) + + original_invoice_references = db.session.query(InvoiceReference.invoice_number) \ + .filter(InvoiceReference.is_consolidated.is_(False)) \ + .filter(InvoiceReference.status_code == InvoiceReferenceStatus.CANCELLED.value) \ + .filter(InvoiceReference.invoice_id.in_(consolidated_invoice_references)) \ + .distinct(InvoiceReference.invoice_number) \ + .all() + return original_invoice_references + class InvoiceReferenceSchema(BaseSchema): # pylint: disable=too-many-ancestors """Main schema used to serialize the invoice reference.""" diff --git a/pay-api/src/pay_api/resources/v1/payment.py b/pay-api/src/pay_api/resources/v1/payment.py index cbd6686ee..b5c81da45 100644 --- a/pay-api/src/pay_api/resources/v1/payment.py +++ b/pay-api/src/pay_api/resources/v1/payment.py @@ -73,15 +73,18 @@ def post_account_payment(account_id: str): ).asdict(), HTTPStatus.CREATED else: is_retry_payment: bool = request.args.get('retryFailedPayment', 'false').lower() == 'true' - pay_outstanding_balance: bool = False + pay_outstanding_balance = False + all_invoice_statuses = False if flags.is_on('enable-eft-payment-method', default=False): pay_outstanding_balance = request.args.get('payOutstandingBalance', 'false').lower() == 'true' + all_invoice_statuses = request.args.get('allInvoiceStatuses', 'false').lower() == 'true' response, status = PaymentService.create_account_payment( auth_account_id=account_id, is_retry_payment=is_retry_payment, - pay_outstanding_balance=pay_outstanding_balance + pay_outstanding_balance=pay_outstanding_balance, + all_invoice_statuses=all_invoice_statuses, ).asdict(), HTTPStatus.CREATED current_app.logger.debug('>post_account_payment') diff --git a/pay-api/src/pay_api/services/invoice.py b/pay-api/src/pay_api/services/invoice.py index 196fae833..3e0751609 100644 --- a/pay-api/src/pay_api/services/invoice.py +++ b/pay-api/src/pay_api/services/invoice.py @@ -331,6 +331,11 @@ def created_on(self): """Return the created date.""" return self._dao.created_on + @created_on.setter + def created_on(self, value: datetime): + """Set the created date.""" + self._dao.created_on = value + def commit(self): """Save the information to the DB.""" return self._dao.commit() diff --git a/pay-api/src/pay_api/services/payment.py b/pay-api/src/pay_api/services/payment.py index b61d24491..f433a5f67 100644 --- a/pay-api/src/pay_api/services/payment.py +++ b/pay-api/src/pay_api/services/payment.py @@ -15,7 +15,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timezone from decimal import Decimal from typing import Dict, List, Optional, Tuple @@ -32,7 +32,6 @@ from pay_api.models.invoice import InvoiceSchema, InvoiceSearchModel from pay_api.models.invoice_reference import InvoiceReference as InvoiceReferenceModel from pay_api.models.payment import PaymentSchema -from pay_api.models.payment_line_item import PaymentLineItem from pay_api.services.cfs_service import CFSService from pay_api.utils.converter import Converter from pay_api.utils.enums import ( @@ -519,7 +518,7 @@ def _prepare_csv_data(results): return cells @staticmethod - def find_payment_for_invoice(invoice_id: int): + def find_payment_for_invoice(invoice_id: int) -> Payment: """Find payment for by invoice.""" payment_dao = PaymentModel.find_payment_for_invoice(invoice_id) payment: Payment = None @@ -531,7 +530,8 @@ def find_payment_for_invoice(invoice_id: int): @staticmethod def create_account_payment(auth_account_id: str, is_retry_payment: bool, - pay_outstanding_balance: bool = False) -> Payment: + pay_outstanding_balance=False, + all_invoice_statuses=False) -> Payment: """Create a payment record for the account.""" payment: Payment = None if is_retry_payment: @@ -543,7 +543,7 @@ def create_account_payment(auth_account_id: str, is_retry_payment: bool, if pay_outstanding_balance and len(payments) == 0: # First time attempting to pay outstanding invoices and there were no previous failures # if there is a failure payment we can follow the normal is_retry_payment flow - return Payment._consolidate_invoices_and_pay(auth_account_id) + return Payment._consolidate_invoices_and_pay(auth_account_id, all_invoice_statuses) if len(payments) == 1: can_consolidate_invoice = False @@ -600,20 +600,28 @@ def _populate(dao: PaymentModel): return payment @staticmethod - def create_consolidated_invoices_payment(consolidated_invoices: List[InvoiceModel], - consolidated_line_items: List[PaymentLineItem], + def create_consolidated_invoices_payment(invoices: List[InvoiceModel], cfs_account: CfsAccountModel, - pay_account: PaymentAccountModel, - invoice_total: Decimal): + randomize_invoice_number=False): """Create payment for consolidated invoices and update invoice references.""" - invoice_number = str(consolidated_invoices[-1].id) + '-C' - prefixed_invoice_number = generate_transaction_number(invoice_number) + inv_no_prefix = str(invoices[-1].id) + '-C' + if randomize_invoice_number: + # We timestamp appended because it's possible we could have 5 invoices, + # 1 out of the 5 invoices were recently paid but aren't the last invoice in the list. + # This would change the total, so the consolidated invoices amount would change. + # Earlier we already reversed existing consolidated invoices. + # We can't really adjust invoices, because we aren't getting the line information back, + # so we'll have to sort to reversing and recreating the consolidated invoices. + inv_no_prefix = f"{str(invoices[-1].id)}-{datetime.now(tz=timezone.utc).strftime('%H%M%S')}-C" + invoice_number = generate_transaction_number(inv_no_prefix) invoice_exists = False + invoice_total = sum(invoice.total - invoice.paid for invoice in invoices) + consolidated_line_items = [item for invoice in invoices for item in invoice.payment_line_items] try: invoice_response = CFSService.get_invoice(cfs_account=cfs_account, - inv_number=prefixed_invoice_number) + inv_number=invoice_number) - invoice_exists = invoice_response.get('invoice_number', None) == prefixed_invoice_number + invoice_exists = invoice_response.get('invoice_number', None) == invoice_number invoice_total_matches = Decimal(invoice_response.get('total', '0')) == invoice_total if invoice_exists and not invoice_total_matches: @@ -625,63 +633,73 @@ def create_consolidated_invoices_payment(consolidated_invoices: List[InvoiceMode if not invoice_exists: invoice_response = CFSService.create_account_invoice( - transaction_number=invoice_number, + transaction_number=inv_no_prefix, line_items=consolidated_line_items, cfs_account=cfs_account) - invoice_number: str = invoice_response.get('invoice_number') - - for invoice in consolidated_invoices: - inv_ref: InvoiceReferenceModel = InvoiceReferenceModel.find_by_invoice_id_and_status( + for invoice in invoices: + inv_ref = InvoiceReferenceModel.find_by_invoice_id_and_status( invoice_id=invoice.id, status_code=InvoiceReferenceStatus.ACTIVE.value) - - # CFS invoice creation job rolls up EFT invoices, there may not be a reference for every invoice - if inv_ref is not None: + if inv_ref and inv_ref.invoice_number != invoice_number: inv_ref.status_code = InvoiceReferenceStatus.CANCELLED.value - - InvoiceReferenceModel(invoice_id=invoice.id, - status_code=InvoiceReferenceStatus.ACTIVE.value, - invoice_number=invoice_number, - reference_number=invoice_response.get('pbc_ref_number')).flush() + inv_ref.flush() + if not inv_ref or inv_ref.status_code == InvoiceReferenceStatus.CANCELLED.value: + InvoiceReferenceModel(invoice_id=invoice.id, + status_code=InvoiceReferenceStatus.ACTIVE.value, + invoice_number=invoice_number, + reference_number=invoice_response.get('pbc_ref_number'), + is_consolidated=True).flush() payment = Payment.create(payment_method=PaymentMethod.CC.value, payment_system=PaymentSystem.PAYBC.value, invoice_number=invoice_number, invoice_amount=invoice_total, - payment_account_id=pay_account.id) + payment_account_id=cfs_account.account_id) return payment, invoice_number @classmethod - def _consolidate_invoices_and_pay(cls, auth_account_id: str) -> Payment: + def _consolidate_invoices_and_pay(cls, auth_account_id: str, all_invoice_statuses=False) -> Payment: """Find outstanding invoices and create a payment. - 1. Create new consolidated invoice in CFS. - 2. Create new invoice reference records. - 3. Create new payment records for the invoice as CREATED. + 1. Reverse existing invoices in CFS with credit memos. + 2. Create new consolidated invoice in CFS. + 3. Create new invoice reference records. + 4. Create new payment records for the invoice as CREATED. """ pay_account = PaymentAccountModel.find_by_auth_account_id(auth_account_id) cfs_account = CfsAccountModel.find_effective_by_payment_method(pay_account.id, pay_account.payment_method) - - # May require some review and thought, will be done in another ticket 22655 - outstanding_invoices: List[InvoiceModel] = ( - InvoiceModel.find_invoices_by_status_for_account(pay_account.id, - [InvoiceStatus.APPROVED.value, - InvoiceStatus.PARTIAL.value, - InvoiceStatus.OVERDUE.value, - InvoiceStatus.SETTLEMENT_SCHEDULED] - )) + invoice_statuses = [ + InvoiceStatus.OVERDUE.value + ] + if all_invoice_statuses: + invoice_statuses.extend([ + InvoiceStatus.APPROVED.value, + InvoiceStatus.PARTIAL.value, + InvoiceStatus.SETTLEMENT_SCHEDULED + ]) + + outstanding_invoices = InvoiceModel.find_invoices_by_status_for_account(pay_account.id, invoice_statuses) consolidated_invoices: List[InvoiceModel] = [] - consolidated_line_items: List[PaymentLineItem] = [] - - invoice_total = Decimal('0') + reversed_consolidated_invoices = set() for invoice in outstanding_invoices: + for invoice_reference in invoice.references: + invoice_number = invoice_reference.invoice_number + if ( + invoice_number not in reversed_consolidated_invoices and + invoice_reference.status_code == InvoiceReferenceStatus.ACTIVE.value and + invoice_reference.is_consolidated is True + ): + reversed_consolidated_invoices.add(invoice_number) + CFSService.reverse_invoice(inv_number=invoice_number) + # Don't reverse original invoice here, we need to do so after receiving payment, otherwise we'll have a + # consolidated invoice reference active while the regular invoice is reversed. (Scenario where they don't + # go through the CC NSF process) This doesn't work well for our EFT job. consolidated_invoices.append(invoice) - invoice_total += invoice.total - invoice.paid - consolidated_line_items.append(*invoice.payment_line_items) - payment, _ = cls.create_consolidated_invoices_payment(consolidated_invoices, consolidated_line_items, - cfs_account, pay_account, invoice_total) + payment, _ = cls.create_consolidated_invoices_payment(consolidated_invoices, + cfs_account, + randomize_invoice_number=True) BaseModel.commit() return payment @@ -699,22 +717,16 @@ def _consolidate_payments(cls, auth_account_id: str, failed_payments: List[Payme cfs_account = CfsAccountModel.find_effective_by_payment_method(pay_account.id, pay_account.payment_method) consolidated_invoices: List[InvoiceModel] = [] - consolidated_line_items: List[PaymentLineItem] = [] - - invoice_total = Decimal('0') for failed_payment in failed_payments: + # Note this works for PAD, but wont work for EFT as users could try to consolidate + # but still pay via EFT instead of going through with credit card. CFSService.reverse_invoice(inv_number=failed_payment.invoice_number) # Find all invoices for this payment. # Add all line items to the array for invoice in InvoiceModel.find_invoices_for_payment(payment_id=failed_payment.id): consolidated_invoices.append(invoice) - invoice_total += invoice.total - consolidated_line_items.append(*invoice.payment_line_items) - payment, invoice_number = cls.create_consolidated_invoices_payment(consolidated_invoices, - consolidated_line_items, - cfs_account, pay_account, - invoice_total) + payment, invoice_number = cls.create_consolidated_invoices_payment(consolidated_invoices, cfs_account) # Update all failed payment with consolidated invoice number. for failed_payment in failed_payments: diff --git a/pay-api/src/pay_api/services/payment_account.py b/pay-api/src/pay_api/services/payment_account.py index c0f4133b5..d68367740 100644 --- a/pay-api/src/pay_api/services/payment_account.py +++ b/pay-api/src/pay_api/services/payment_account.py @@ -29,6 +29,7 @@ from pay_api.models import AccountFeeSchema from pay_api.models import CfsAccount as CfsAccountModel from pay_api.models import Invoice as InvoiceModel +from pay_api.models import InvoiceReference as InvoiceReferenceModel from pay_api.models import PaymentAccount as PaymentAccountModel from pay_api.models import PaymentAccountSchema from pay_api.models import StatementRecipients as StatementRecipientModel @@ -606,42 +607,60 @@ def create_account_event_payload(self, event_type: str, receipt_info: dict = Non return payload @staticmethod - def unlock_frozen_accounts(payment_id: int, payment_account_id: int): + def unlock_frozen_accounts(payment_id: int, payment_account_id: int, invoice_number: str): """Unlock frozen accounts.""" - pay_account: PaymentAccount = PaymentAccount.find_by_id(payment_account_id) - if pay_account.cfs_account_status == CfsAccountStatus.FREEZE.value: - current_app.logger.info(f'Unlocking Frozen Account {pay_account.auth_account_id}') - cfs_account: CfsAccountModel = CfsAccountModel.find_effective_by_payment_method(pay_account.id, - PaymentMethod.PAD.value) + pay_account = PaymentAccountModel.find_by_id(payment_account_id) + unlocked = False + if pay_account.has_nsf_invoices: + current_app.logger.info(f'Unlocking PAD Frozen Account {pay_account.auth_account_id}') + cfs_account = CfsAccountModel.find_effective_by_payment_method(pay_account.id, + PaymentMethod.PAD.value) CFSService.update_site_receipt_method(cfs_account, receipt_method=RECEIPT_METHOD_PAD_DAILY) - payment_account_model = PaymentAccountModel.find_by_id(payment_account_id) - payment_account_model.has_nsf_invoices = None + pay_account.has_nsf_invoices = None pay_account.save() cfs_account.status = CfsAccountStatus.ACTIVE.value cfs_account.save() + unlocked = True + elif pay_account.has_overdue_invoices: + # Reverse original invoices here, because users can still cancel out of CC payment process and pay via EFT. + # Note we do the opposite of this in the EFT task, but at a smaller scale (one invoice at a time.) + # Possible some of these could already be reversed. + for original_invoice_number in InvoiceReferenceModel.find_non_consolidated_invoice_numbers(invoice_number): + try: + CFSService.reverse_invoice(original_invoice_number) + except Exception: # NOQA pylint: disable=broad-except + current_app.logger.error(f'Error reversing invoice number: {original_invoice_number}', + exc_info=True) + current_app.logger.info(f'Unlocking EFT Frozen Account {pay_account.auth_account_id}') + pay_account.has_overdue_invoices = None + pay_account.save() + unlocked = True + if not unlocked: + return - receipt_info = ReceiptService.get_nsf_receipt_details(payment_id) - payload = pay_account.create_account_event_payload( - QueueMessageTypes.NSF_UNLOCK_ACCOUNT.value, - receipt_info=receipt_info - ) + receipt_info = ReceiptService.get_nsf_receipt_details(payment_id) + pay_account_service = PaymentAccount.find_by_id(payment_account_id) + payload = pay_account_service.create_account_event_payload( + QueueMessageTypes.NSF_UNLOCK_ACCOUNT.value, + receipt_info=receipt_info + ) - try: - gcp_queue_publisher.publish_to_queue( - QueueMessage( - source=QueueSources.PAY_API.value, - message_type=QueueMessageTypes.NSF_UNLOCK_ACCOUNT.value, - payload=payload, - topic=current_app.config.get('AUTH_EVENT_TOPIC') - ) + try: + gcp_queue_publisher.publish_to_queue( + QueueMessage( + source=QueueSources.PAY_API.value, + message_type=QueueMessageTypes.NSF_UNLOCK_ACCOUNT.value, + payload=payload, + topic=current_app.config.get('AUTH_EVENT_TOPIC') ) - except Exception as e: # NOQA pylint: disable=broad-except - current_app.logger.error(e) - current_app.logger.error( - 'Notification to Queue failed for the Unlock Account %s - %s', pay_account.auth_account_id, - pay_account.name) - capture_message( - f'Notification to Queue failed for the Unlock Account : {payload}.', level='error') + ) + except Exception as e: # NOQA pylint: disable=broad-except + current_app.logger.error(e, exc_info=True) + current_app.logger.error( + 'Notification to Queue failed for the Unlock Account %s - %s', pay_account.auth_account_id, + pay_account.name) + capture_message( + f'Notification to Queue failed for the Unlock Account : {payload}.', level='error') @classmethod def delete_account(cls, auth_account_id: str) -> PaymentAccount: diff --git a/pay-api/src/pay_api/services/payment_transaction.py b/pay-api/src/pay_api/services/payment_transaction.py index c11e8ae3b..510471fb7 100644 --- a/pay-api/src/pay_api/services/payment_transaction.py +++ b/pay-api/src/pay_api/services/payment_transaction.py @@ -225,7 +225,7 @@ def create_transaction_for_invoice(invoice_id: int, request_json: Dict) -> Payme ) # Check if there is a payment created. If not, create a payment record with status CREATED - payment: Payment = Payment.find_payment_for_invoice(invoice_id) + payment = Payment.find_payment_for_invoice(invoice_id) if not payment: # Transaction is against payment, so create a payment if not present. invoice_reference = InvoiceReference.find_active_reference_by_invoice_id(invoice.id) @@ -414,9 +414,11 @@ def update_transaction(transaction_id: uuid, # pylint: disable=too-many-locals if payment.payment_status_code == PaymentStatus.COMPLETED.value: active_failed_payments = Payment.get_failed_payments(auth_account_id=payment_account.auth_account_id) current_app.logger.info('active_failed_payments %s', active_failed_payments) - if not active_failed_payments: + # Note this will take some thought if we have multiple payment methods running at once in the future. + if not active_failed_payments or payment_account.has_overdue_invoices: PaymentAccount.unlock_frozen_accounts(payment_id=payment.id, - payment_account_id=payment.payment_account_id) + payment_account_id=payment.payment_account_id, + invoice_number=payment.invoice_number) transaction = PaymentTransaction.__wrap_dao(transaction_dao) @@ -447,8 +449,8 @@ def _update_receipt_details(invoices, payment, receipt_details, transaction_dao) invoice.payment_date = datetime.now(tz=timezone.utc) invoice_reference = InvoiceReference.find_active_reference_by_invoice_id(invoice.id) invoice_reference.status_code = InvoiceReferenceStatus.COMPLETED.value - # TODO If it's not PAD, publish message. Refactor and move to pay system service later. - if invoice.payment_method_code != PaymentMethod.PAD.value: + # TODO If it's not PAD/EFT, publish message. Refactor and move to pay system service later. + if invoice.payment_method_code not in [PaymentMethod.PAD.value, PaymentMethod.EFT.value]: current_app.logger.info(f'Release record for invoice : {invoice.id} ') PaymentTransaction.publish_status(transaction_dao, invoice) diff --git a/pay-api/src/pay_api/services/receipt.py b/pay-api/src/pay_api/services/receipt.py index 877a02aee..f8c9c18df 100644 --- a/pay-api/src/pay_api/services/receipt.py +++ b/pay-api/src/pay_api/services/receipt.py @@ -20,6 +20,7 @@ from sbc_common_components.utils.camel_case_response import camelcase_dict from pay_api.exceptions import BusinessException +from pay_api.models import Payment as PaymentModel from pay_api.models import PaymentMethod as PaymentMethodModel from pay_api.models import Receipt as ReceiptModel from pay_api.utils.enums import ( @@ -209,11 +210,19 @@ def get_nsf_receipt_details(payment_id): invoices = Invoice.find_invoices_for_payment(payment_id, InvoiceReferenceStatus.COMPLETED.value) nsf_invoice = next((invoice for invoice in invoices if invoice.payment_method_code == PaymentMethod.CC.value), None) - invoice_reference = InvoiceReference.find_completed_reference_by_invoice_id(nsf_invoice.id) - receipt_details['invoiceNumber'] = invoice_reference.invoice_number - receipt_details['receiptNumber'] = nsf_invoice.receipts[0].receipt_number + payment = PaymentModel.find_by_id(payment_id) + receipt_details['invoiceNumber'] = payment.invoice_number + receipt_details['receiptNumber'] = payment.receipt_number receipt_details['paymentMethodDescription'] = 'Credit Card' - non_nsf_invoices = [inv for inv in invoices if inv.id != nsf_invoice.id] + non_nsf_invoices = [inv for inv in invoices if nsf_invoice is None or inv.id != nsf_invoice.id] + # We don't generate a CC invoice for EFT overdue payments. + if not nsf_invoice: + nsf_invoice = Invoice() + nsf_invoice.created_on = payment.payment_date + nsf_invoice.paid = 0 + nsf_invoice.payment_line_items = [] + nsf_invoice.service_fees = 0 + nsf_invoice.total = 0 nsf_invoice.details = [] for invoice in non_nsf_invoices: nsf_invoice.payment_line_items.extend(invoice.payment_line_items) diff --git a/pay-api/tests/unit/api/test_payment.py b/pay-api/tests/unit/api/test_payment.py index fd5b80ce0..515da7a8c 100755 --- a/pay-api/tests/unit/api/test_payment.py +++ b/pay-api/tests/unit/api/test_payment.py @@ -18,9 +18,12 @@ """ import json from datetime import datetime, timezone +from unittest.mock import patch +from pay_api.models.payment import Payment as PaymentModel from pay_api.models.payment_account import PaymentAccount -from pay_api.utils.enums import PaymentMethod, Role +from pay_api.utils.enums import InvoiceReferenceStatus, InvoiceStatus, PaymentMethod, Role +from pay_api.utils.util import generate_transaction_number from tests.utilities.base_test import ( factory_invoice, factory_invoice_reference, factory_payment, factory_payment_account, factory_payment_line_item, get_claims, token_header) @@ -90,3 +93,53 @@ def test_create_eft_payment(session, client, jwt, app): data=json.dumps(payload)) assert rv.status_code == 201 assert rv.json.get('paymentMethod') == PaymentMethod.EFT.value + + +def test_eft_consolidated_payments(session, client, jwt, app): + """Assert we can consolidate invoices for EFT.""" + # Called when pressing next on the consolidated payments (paying for overdue EFT as well) page in auth-web. + token = jwt.create_jwt(get_claims(), token_header) + headers = {'Authorization': f'Bearer {token}', 'content-type': 'application/json'} + payment_account = factory_payment_account(payment_method_code=PaymentMethod.EFT.value).save() + invoice_with_reference = factory_invoice(payment_account, paid=0, total=100, + status_code=InvoiceStatus.APPROVED.value) + invoice_with_reference.save() + factory_payment_line_item(invoice_id=invoice_with_reference.id, fee_schedule_id=1).save() + factory_invoice_reference(invoice_with_reference.id, invoice_number=invoice_with_reference).save() + + invoice_without_reference = factory_invoice( + payment_account, paid=0, total=100, status_code=InvoiceStatus.APPROVED.value) + invoice_without_reference.save() + factory_payment_line_item(invoice_id=invoice_without_reference.id, fee_schedule_id=1).save() + + invoice_exist_consolidation = factory_invoice( + payment_account, paid=0, total=100, status_code=InvoiceStatus.APPROVED.value) + invoice_exist_consolidation.save() + existing_consolidated_invoice_number = generate_transaction_number(str(invoice_exist_consolidation.id) + '-C') + factory_payment_line_item(invoice_id=invoice_exist_consolidation.id, fee_schedule_id=1).save() + factory_invoice_reference(invoice_exist_consolidation.id, + invoice_number=existing_consolidated_invoice_number, + is_consolidated=True).save() + + with patch('pay_api.services.CFSService.reverse_invoice') as mock_reverse_invoice: + rv = client.post(f'/api/v1/accounts/{payment_account.auth_account_id}/payments?retryFailedPayment=true' + '&payOutstandingBalance=true&allInvoiceStatuses=true', + headers=headers) + # Called once for our invoice with a reference the other two this should skip for. + mock_reverse_invoice.assert_called_once() + assert rv.status_code == 201 + + assert len(invoice_with_reference.references) == 2 + assert invoice_with_reference.references[0].status_code == InvoiceReferenceStatus.CANCELLED.value + assert invoice_with_reference.references[1].status_code == InvoiceReferenceStatus.ACTIVE.value + assert invoice_with_reference.references[1].is_consolidated is True + assert len(invoice_without_reference.references) == 1 + assert invoice_without_reference.references[0].status_code == InvoiceReferenceStatus.ACTIVE.value + assert invoice_without_reference.references[0].is_consolidated is True + assert len(invoice_exist_consolidation.references) == 2 + assert invoice_exist_consolidation.references[0].status_code == InvoiceReferenceStatus.CANCELLED.value + assert invoice_exist_consolidation.references[0].is_consolidated is True + assert invoice_exist_consolidation.references[1].status_code == InvoiceReferenceStatus.ACTIVE.value + assert invoice_exist_consolidation.references[1].is_consolidated is True + assert PaymentModel.query.filter(PaymentModel.invoice_number == + invoice_exist_consolidation.references[1].invoice_number).first() diff --git a/pay-api/tests/unit/api/test_refund.py b/pay-api/tests/unit/api/test_refund.py index 6cc895c93..e026dc412 100644 --- a/pay-api/tests/unit/api/test_refund.py +++ b/pay-api/tests/unit/api/test_refund.py @@ -107,7 +107,7 @@ def test_create_eft_refund(session, client, jwt, app): ), headers=headers) inv_id2 = rv.json.get('id') - factory_invoice_reference(inv_id2, 'REG3904393').save() + factory_invoice_reference(inv_id2, invoice_number='REG3904393').save() token = jwt.create_jwt(get_claims(app_request=app, role=Role.SYSTEM.value), token_header) headers = {'Authorization': f'Bearer {token}', 'content-type': 'application/json'} diff --git a/pay-api/tests/unit/services/test_distribution_code.py b/pay-api/tests/unit/services/test_distribution_code.py index 782d268fd..b337b4cb4 100644 --- a/pay-api/tests/unit/services/test_distribution_code.py +++ b/pay-api/tests/unit/services/test_distribution_code.py @@ -84,6 +84,7 @@ def test_update_distribution(session, public_user_mock, monkeypatch): factory_payment(invoice_number=invoice_reference.invoice_number, payment_method_code=PaymentMethod.DIRECT_PAY.value, + payment_account_id=payment_account.id, invoice_amount=30).save() distribution_id = line.fee_distribution_id diff --git a/pay-api/tests/unit/services/test_invoice.py b/pay-api/tests/unit/services/test_invoice.py index 0b1fcdc82..f43a7df2c 100644 --- a/pay-api/tests/unit/services/test_invoice.py +++ b/pay-api/tests/unit/services/test_invoice.py @@ -66,7 +66,7 @@ def test_invoice_saved_from_new(session): assert invoice.refund is None assert invoice.payment_date is None assert invoice.total is not None - assert invoice.paid is None + assert invoice.paid is not None assert invoice.payment_line_items is not None assert invoice.folio_number is not None assert invoice.business_identifier is not None @@ -94,7 +94,7 @@ def test_invoice_find_by_id(session): assert invoice.refund is None assert invoice.payment_date is None assert invoice.total is not None - assert invoice.paid is None + assert invoice.paid is not None assert not invoice.payment_line_items @@ -116,7 +116,7 @@ def test_invoice_with_temproary_business_identifier(session): assert invoice.refund is None assert invoice.payment_date is None assert invoice.total is not None - assert invoice.paid is None + assert invoice.paid is not None assert invoice.payment_line_items is not None assert invoice.folio_number is not None assert invoice.business_identifier is not None diff --git a/pay-api/tests/unit/services/test_payment.py b/pay-api/tests/unit/services/test_payment.py index 3f6bc6f09..6313fea82 100644 --- a/pay-api/tests/unit/services/test_payment.py +++ b/pay-api/tests/unit/services/test_payment.py @@ -132,7 +132,7 @@ def test_search_payment_history(session, test_name, search_filter, view_all, if expected_key == 'id' and not search_filter: search_filter = {'id': invoice.id} expected_value = invoice.id - factory_invoice_reference(invoice.id, f'123{i}').save() + factory_invoice_reference(invoice_id=invoice.id, invoice_number=f'123{i}').save() line_item = factory_payment_line_item(invoice_id=invoice.id, fee_schedule_id=1, description=f'test{i}') line_item.save() @@ -147,7 +147,7 @@ def test_search_payment_history(session, test_name, search_filter, view_all, if expected_key == 'id' and not search_filter: search_filter = {'id': invoice.id} expected_value = invoice.id - factory_invoice_reference(invoice.id, '1231').save() + factory_invoice_reference(invoice.id, invoice_number='1231').save() line_item = factory_payment_line_item(invoice_id=invoice.id, fee_schedule_id=1, description='test1') line_item.save() diff --git a/pay-api/tests/unit/services/test_payment_transaction.py b/pay-api/tests/unit/services/test_payment_transaction.py index 8f0ee90b9..285b5dbb3 100644 --- a/pay-api/tests/unit/services/test_payment_transaction.py +++ b/pay-api/tests/unit/services/test_payment_transaction.py @@ -26,8 +26,10 @@ from pay_api.exceptions import BusinessException from pay_api.models import CfsAccount, FeeSchedule, Invoice, Payment from pay_api.services.hashing import HashingService +from pay_api.services.payment_service import Payment as PaymentService from pay_api.services.payment_transaction import PaymentTransaction as PaymentTransactionService -from pay_api.utils.enums import CfsAccountStatus, PaymentMethod, PaymentStatus, TransactionStatus +from pay_api.utils.enums import ( + CfsAccountStatus, InvoiceReferenceStatus, InvoiceStatus, PaymentMethod, PaymentStatus, TransactionStatus) from pay_api.utils.errors import Error from tests import skip_in_pod from tests.utilities.base_test import ( @@ -151,7 +153,8 @@ def test_transaction_update(session, public_user_mock): line = factory_payment_line_item(invoice.id, fee_schedule_id=fee_schedule.fee_schedule_id) line.save() - payment: Payment = factory_payment(invoice_number=invoice_reference.invoice_number).save() + payment: Payment = factory_payment(invoice_number=invoice_reference.invoice_number, + payment_account_id=payment_account.id).save() transaction = PaymentTransactionService.create_transaction_for_invoice(invoice.id, get_paybc_transaction_request()) transaction = PaymentTransactionService.update_transaction(transaction.id, @@ -181,7 +184,8 @@ def test_transaction_update_with_no_receipt(session): line = factory_payment_line_item(invoice.id, fee_schedule_id=fee_schedule.fee_schedule_id) line.save() - factory_payment(invoice_number=invoice_reference.invoice_number).save() + factory_payment(invoice_number=invoice_reference.invoice_number, + payment_account_id=payment_account.id).save() transaction = PaymentTransactionService.create_transaction_for_invoice(invoice.id, get_paybc_transaction_request()) transaction = PaymentTransactionService.update_transaction(transaction.id, pay_response_url=None) @@ -210,7 +214,8 @@ def test_transaction_update_completed(session, public_user_mock): line = factory_payment_line_item(invoice.id, fee_schedule_id=fee_schedule.fee_schedule_id) line.save() - factory_payment(invoice_number=invoice_reference.invoice_number).save() + factory_payment(invoice_number=invoice_reference.invoice_number, + payment_account_id=payment_account.id).save() transaction = PaymentTransactionService.create_transaction_for_invoice(invoice.id, get_paybc_transaction_request()) transaction = PaymentTransactionService.update_transaction(transaction.id, @@ -586,7 +591,8 @@ def test_patch_transaction_for_nsf_payment(session, monkeypatch): # Patch transaction and check the status of records inv_number_1 = 'REG00001' payment_account = factory_payment_account(cfs_account_status=CfsAccountStatus.FREEZE.value, - payment_method_code='PAD').save() + payment_method_code='PAD', + has_nsf_invoices=datetime.now(tz=timezone.utc)).save() invoice_1 = factory_invoice(payment_account, total=100) invoice_1.save() factory_payment_line_item(invoice_id=invoice_1.id, fee_schedule_id=1).save() @@ -624,7 +630,47 @@ def get_receipt(cls, payment_account, pay_response_url: str, payment_2 = Payment.find_by_id(payment_2.id) assert payment_2.payment_status_code == 'COMPLETED' - invoice_1: Invoice = Invoice.find_by_id(invoice_1.id) + invoice_1 = Invoice.find_by_id(invoice_1.id) assert invoice_1.invoice_status_code == 'PAID' cfs_account = CfsAccount.find_effective_by_payment_method(payment_account.id, PaymentMethod.PAD.value) assert cfs_account.status == 'ACTIVE' + assert payment_account.has_nsf_invoices is None + + +def test_patch_transaction_for_eft_overdue(session, monkeypatch): + """Assert we can unlock for EFT.""" + inv_number_1 = 'REG00001' + payment_account = factory_payment_account(cfs_account_status=CfsAccountStatus.ACTIVE.value, + payment_method_code=PaymentMethod.EFT.value, + has_overdue_invoices=datetime.now(tz=timezone.utc)).save() + invoice_1 = factory_invoice(payment_account, total=100, status_code=InvoiceStatus.APPROVED.value) + invoice_1.save() + invoice_2 = factory_invoice(payment_account, total=100, status_code=InvoiceStatus.OVERDUE.value) + invoice_2.save() + factory_payment_line_item(invoice_id=invoice_1.id, fee_schedule_id=1).save() + factory_payment_line_item(invoice_id=invoice_2.id, fee_schedule_id=1).save() + original_invoice_reference = factory_invoice_reference(invoice_2.id, invoice_number=inv_number_1).save() + + def get_receipt(cls, payment_account, pay_response_url: str, + invoice_reference): # pylint: disable=unused-argument; mocks of library methods + return '1234567890', datetime.now(tz=timezone.utc), 100.00 + + monkeypatch.setattr('pay_api.services.paybc_service.PaybcService.get_receipt', get_receipt) + payment = PaymentService._consolidate_invoices_and_pay( # pylint: disable=protected-access + payment_account.auth_account_id, + all_invoice_statuses=False) + assert original_invoice_reference.status_code == InvoiceReferenceStatus.CANCELLED.value, \ + 'Invoice reference should be CANCELLED a new invoice reference should be created' + + txn = PaymentTransactionService.create_transaction_for_payment(payment.id, get_paybc_transaction_request()) + txn = PaymentTransactionService.update_transaction(txn.id, pay_response_url='receipt_number=123451') + + assert txn.status_code == InvoiceReferenceStatus.COMPLETED.value + payment = Payment.find_by_id(payment.id) + assert payment.payment_status_code == InvoiceReferenceStatus.COMPLETED.value + + invoice_1 = Invoice.find_by_id(invoice_1.id) + assert invoice_1.invoice_status_code == InvoiceStatus.APPROVED.value, 'APPROVED should not be updated' + invoice_2 = Invoice.find_by_id(invoice_2.id) + assert invoice_2.invoice_status_code == InvoiceStatus.PAID.value, 'OVERDUE invoice should be PAID' + assert payment_account.has_overdue_invoices is None, 'This flag should be cleared.' diff --git a/pay-api/tests/unit/services/test_statement.py b/pay-api/tests/unit/services/test_statement.py index 25fd3641e..b1bceeae4 100644 --- a/pay-api/tests/unit/services/test_statement.py +++ b/pay-api/tests/unit/services/test_statement.py @@ -227,8 +227,9 @@ def test_get_interim_statement_change_away_from_eft(session, admin_users_mock): invoice_create_date = localize_date(datetime(2023, 10, 9, 12, 0)) monthly_invoice = factory_invoice(payment_account=account, created_on=invoice_create_date, payment_method_code=PaymentMethod.EFT.value, - status_code=InvoiceStatus.APPROVED.value, - total=50).save() + status_code=InvoiceStatus.PAID.value, + total=50, + paid=50).save() assert monthly_invoice is not None update_date = localize_date(datetime(2023, 10, 12, 12, 0)) diff --git a/pay-api/tests/utilities/base_test.py b/pay-api/tests/utilities/base_test.py index 47c9a0c1a..aff89752c 100644 --- a/pay-api/tests/utilities/base_test.py +++ b/pay-api/tests/utilities/base_test.py @@ -376,6 +376,8 @@ def factory_payment_account(payment_system_code: str = 'PAYBC', payment_method_c bcol_user_id='test', auth_account_id: str = '1234', cfs_account_status: str = CfsAccountStatus.ACTIVE.value, + has_nsf_invoices=None, + has_overdue_invoices=None, name=None, branch_name=None): """Return Factory.""" @@ -388,7 +390,9 @@ def factory_payment_account(payment_system_code: str = 'PAYBC', payment_method_c branch_name=branch_name, payment_method=payment_method_code, pad_activation_date=datetime.now(tz=timezone.utc), - eft_enable=False + eft_enable=False, + has_nsf_invoices=has_nsf_invoices, + has_overdue_invoices=has_overdue_invoices ).save() CfsAccount(cfs_party='11111', @@ -507,7 +511,7 @@ def factory_invoice(payment_account, status_code: str = InvoiceStatus.CREATED.va business_identifier: str = 'CP0001234', service_fees: float = 0.0, total=0, - paid=None, + paid=0, payment_method_code: str = PaymentMethod.DIRECT_PAY.value, created_on: datetime = datetime.now(tz=timezone.utc), routing_slip=None, @@ -569,11 +573,15 @@ def factory_payment_transaction( ) -def factory_invoice_reference(invoice_id: int, invoice_number: str = '10021'): +def factory_invoice_reference(invoice_id: int, + status_code=InvoiceReferenceStatus.ACTIVE.value, + invoice_number='10021', + is_consolidated=False): """Return Factory.""" return InvoiceReference(invoice_id=invoice_id, - status_code=InvoiceReferenceStatus.ACTIVE.value, - invoice_number=invoice_number) + status_code=status_code, + invoice_number=invoice_number, + is_consolidated=is_consolidated) def factory_receipt( diff --git a/pay-queue/poetry.lock b/pay-queue/poetry.lock index 19dace6bb..8b312c939 100644 --- a/pay-queue/poetry.lock +++ b/pay-queue/poetry.lock @@ -1969,7 +1969,7 @@ rsa = "4.9" sbc-common-components = {git = "https://github.com/bcgov/sbc-common-components.git", subdirectory = "python"} semver = "3.0.2" sentry-sdk = "1.41.0" -setuptools = "^72.1.0" +setuptools = "^73.0.1" six = "1.16.0" sql-versioning = {git = "https://github.com/bcgov/sbc-connect-common.git", branch = "main", subdirectory = "python/sql-versioning"} sqlalchemy = "2.0.28" @@ -1983,9 +1983,9 @@ werkzeug = "3.0.1" [package.source] type = "git" -url = "https://github.com/bcgov/sbc-pay.git" -reference = "main" -resolved_reference = "69daef06be5a88df8aee6d969fd2d023449e404a" +url = "https://github.com/seeker25/sbc-pay.git" +reference = "22655" +resolved_reference = "d7bae632b5a61b2ebbf9ea1ebf6d1e9bf171e51f" subdirectory = "pay-api" [[package]] @@ -2693,19 +2693,19 @@ tornado = ["tornado (>=5)"] [[package]] name = "setuptools" -version = "72.2.0" +version = "73.0.1" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-72.2.0-py3-none-any.whl", hash = "sha256:f11dd94b7bae3a156a95ec151f24e4637fb4fa19c878e4d191bfb8b2d82728c4"}, - {file = "setuptools-72.2.0.tar.gz", hash = "sha256:80aacbf633704e9c8bfa1d99fa5dd4dc59573efcf9e4042c13d3bcef91ac2ef9"}, + {file = "setuptools-73.0.1-py3-none-any.whl", hash = "sha256:b208925fcb9f7af924ed2dc04708ea89791e24bde0d3020b27df0e116088b34e"}, + {file = "setuptools-73.0.1.tar.gz", hash = "sha256:d59a3e788ab7e012ab2c4baed1b376da6366883ee20d7a5fc426816e3d7b1193"}, ] [package.extras] -core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "ordered-set (>=3.1.1)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.11.*)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (<0.4)", "pytest-ruff (>=0.2.1)", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.11.*)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (<0.4)", "pytest-ruff (>=0.2.1)", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] [[package]] name = "simple-cloudevent" @@ -3102,4 +3102,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "caedf441ab6aa685842334c79065843d23f32e93a3b9b0641ff884c8e54ba98f" +content-hash = "d488fd7e74e7a35343c0fd69455eb40aa9b8ed0797cd3e1d2566ce4c863564a5" diff --git a/pay-queue/pyproject.toml b/pay-queue/pyproject.toml index bf9cce881..447511482 100644 --- a/pay-queue/pyproject.toml +++ b/pay-queue/pyproject.toml @@ -21,7 +21,7 @@ jinja2 = "^3.1.3" protobuf = "4.25.3" launchdarkly-server-sdk = "^8.2.1" cachecontrol = "^0.14.0" -pay-api = {git = "https://github.com/bcgov/sbc-pay.git", subdirectory = "pay-api", branch = "main"} +pay-api = {git = "https://github.com/seeker25/sbc-pay.git", subdirectory = "pay-api", branch = "22655"} pg8000 = "^1.30.5" diff --git a/pay-queue/src/pay_queue/services/payment_reconciliations.py b/pay-queue/src/pay_queue/services/payment_reconciliations.py index 3920796f0..4e7b335e7 100644 --- a/pay-queue/src/pay_queue/services/payment_reconciliations.py +++ b/pay-queue/src/pay_queue/services/payment_reconciliations.py @@ -538,7 +538,7 @@ def _process_failed_payments(row): payment_account: PaymentAccountModel = _get_payment_account(row) # If there is a FAILED payment record for this; it means it's a duplicate event. Ignore it. - payment: PaymentModel = PaymentModel.find_payment_by_invoice_number_and_status( + payment = PaymentModel.find_payment_by_invoice_number_and_status( inv_number, PaymentStatus.FAILED.value ) if payment: diff --git a/pay-queue/tests/integration/factory.py b/pay-queue/tests/integration/factory.py index e748dbf69..6e5757151 100644 --- a/pay-queue/tests/integration/factory.py +++ b/pay-queue/tests/integration/factory.py @@ -119,11 +119,13 @@ def factory_payment_line_item(invoice_id: str, fee_schedule_id: int = 1, filing_ def factory_invoice_reference(invoice_id: int, invoice_number: str = '10021', - status_code: str = InvoiceReferenceStatus.ACTIVE.value): + status_code: str = InvoiceReferenceStatus.ACTIVE.value, + is_consolidated=False): """Return Factory.""" return InvoiceReference(invoice_id=invoice_id, status_code=status_code, - invoice_number=invoice_number).save() + invoice_number=invoice_number, + is_consolidated=is_consolidated).save() def factory_receipt(invoice_id: int, receipt_number: str = '10021'):