diff --git a/pay-api/migrations/versions/2024_01_08_ff245db0cf76_eft_gl_transfer.py b/pay-api/migrations/versions/2024_01_08_ff245db0cf76_eft_gl_transfer.py new file mode 100644 index 000000000..bb5791288 --- /dev/null +++ b/pay-api/migrations/versions/2024_01_08_ff245db0cf76_eft_gl_transfer.py @@ -0,0 +1,69 @@ +""" EFT GL Transfer tracking + +Revision ID: ff245db0cf76 +Revises: eec11500a81e +Create Date: 2024-01-08 08:57:56.456585 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'ff245db0cf76' +down_revision = 'eec11500a81e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + + # EFT gl transfer tracking table + op.create_table('eft_gl_transfers', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('transfer_date', sa.DateTime(), nullable=False), + sa.Column('transfer_type', sa.String(length=50), nullable=False), + sa.Column('transfer_amount', sa.Numeric(precision=19, scale=2), nullable=False), + sa.Column('source_gl', sa.String(length=50), nullable=False), + sa.Column('target_gl', sa.String(length=50), nullable=False), + sa.Column('is_processed', sa.Boolean(), nullable=False), + sa.Column('processed_on', sa.DateTime(), nullable=True), + sa.Column('created_on', sa.DateTime(), nullable=False), + sa.Column('invoice_id', sa.Integer(), nullable=True), + sa.Column('short_name_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['invoice_id'], ['invoices.id'], ), + sa.ForeignKeyConstraint(['short_name_id'], ['eft_short_names.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_eft_gl_transfers_transfer_date'), 'eft_gl_transfers', ['transfer_date'], unique=False) + + # Add credit to invoice link to track what invoice are eft funds applied to + op.create_table('eft_credit_invoice_links', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('invoice_id', sa.Integer(), nullable=False), + sa.Column('eft_credit_id', sa.Integer(), nullable=False), + sa.Column('created_on', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['eft_credit_id'], ['eft_credits.id'], ), + sa.ForeignKeyConstraint(['invoice_id'], ['invoices.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_eft_credit_invoice_links_eft_credit_id'), 'eft_credit_invoice_links', ['eft_credit_id'], unique=False) + op.create_index(op.f('ix_eft_credit_invoice_links_invoice_id'), 'eft_credit_invoice_links', ['invoice_id'], unique=False) + + # Add link between credits and the eft_transaction from TDI17 it came from for easier auditing + op.add_column('eft_credits', sa.Column('eft_transaction_id', sa.Integer(), nullable=True)) + op.create_foreign_key(None, 'eft_credits', 'eft_transactions', ['eft_transaction_id'], ['id']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_eft_gl_transfers_transfer_date'), table_name='eft_gl_transfers') + op.drop_table('eft_gl_transfers') + op.drop_index(op.f('ix_eft_credit_invoice_links_invoice_id'), table_name='eft_credit_invoice_links') + op.drop_index(op.f('ix_eft_credit_invoice_links_eft_credit_id'), table_name='eft_credit_invoice_links') + op.drop_table('eft_credit_invoice_links') + op.drop_column('eft_credits', 'eft_transaction_id') + # ### end Alembic commands ### diff --git a/pay-api/src/pay_api/models/__init__.py b/pay-api/src/pay_api/models/__init__.py index a85d87121..50e066faf 100755 --- a/pay-api/src/pay_api/models/__init__.py +++ b/pay-api/src/pay_api/models/__init__.py @@ -27,7 +27,9 @@ from .disbursement_status_code import DisbursementStatusCode from .distribution_code import DistributionCode, DistributionCodeLink from .eft_credit import EFTCredit +from .eft_credit_invoice_link import EFTCreditInvoiceLink from .eft_file import EFTFile +from .eft_gl_transfers import EFTGLTransfer from .eft_process_status_code import EFTProcessStatusCode from .eft_short_names import EFTShortnames, EFTShortnameSchema from .eft_transaction import EFTTransaction diff --git a/pay-api/src/pay_api/models/eft_credit.py b/pay-api/src/pay_api/models/eft_credit.py index f06f8987c..595b93112 100644 --- a/pay-api/src/pay_api/models/eft_credit.py +++ b/pay-api/src/pay_api/models/eft_credit.py @@ -39,6 +39,7 @@ class EFTCredit(BaseModel): # pylint:disable=too-many-instance-attributes 'amount', 'created_on', 'eft_file_id', + 'eft_transaction_id', 'short_name_id', 'payment_account_id', 'remaining_amount' @@ -54,6 +55,7 @@ class EFTCredit(BaseModel): # pylint:disable=too-many-instance-attributes eft_file_id = db.Column(db.Integer, ForeignKey('eft_files.id'), nullable=False) short_name_id = db.Column(db.Integer, ForeignKey('eft_short_names.id'), nullable=False) payment_account_id = db.Column(db.Integer, ForeignKey('payment_accounts.id'), nullable=True, index=True) + eft_transaction_id = db.Column(db.Integer, ForeignKey('eft_transactions.id'), nullable=True) @classmethod def find_by_payment_account_id(cls, payment_account_id: int): diff --git a/pay-api/src/pay_api/models/eft_credit_invoice_link.py b/pay-api/src/pay_api/models/eft_credit_invoice_link.py new file mode 100644 index 000000000..f0350cfcd --- /dev/null +++ b/pay-api/src/pay_api/models/eft_credit_invoice_link.py @@ -0,0 +1,49 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Model to link invoices with EFT Credits.""" +from datetime import datetime + +from sqlalchemy import ForeignKey + +from .base_model import BaseModel +from .db import db + + +class EFTCreditInvoiceLink(BaseModel): # pylint: disable=too-few-public-methods + """This class manages linkages between EFT Credits and invoices.""" + + __tablename__ = 'eft_credit_invoice_links' + # this mapper is used so that new and old versions of the service can be run simultaneously, + # making rolling upgrades easier + # This is used by SQLAlchemy to explicitly define which fields we're interested + # so it doesn't freak out and say it can't map the structure if other fields are present. + # This could occur from a failed deploy or during an upgrade. + # The other option is to tell SQLAlchemy to ignore differences, but that is ambiguous + # and can interfere with Alembic upgrades. + # + # NOTE: please keep mapper names in alpha-order, easier to track that way + # Exception, id is always first, _fields first + __mapper_args__ = { + 'include_properties': [ + 'id', + 'created_on', + 'eft_credit_id', + 'invoice_id' + ] + } + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + invoice_id = db.Column(db.Integer, ForeignKey('invoices.id'), nullable=False, index=True) + eft_credit_id = db.Column(db.Integer, ForeignKey('eft_credits.id'), nullable=False, index=True) + created_on = db.Column('created_on', db.DateTime, nullable=True, default=datetime.now) diff --git a/pay-api/src/pay_api/models/eft_gl_transfers.py b/pay-api/src/pay_api/models/eft_gl_transfers.py new file mode 100644 index 000000000..ebd0bdfb7 --- /dev/null +++ b/pay-api/src/pay_api/models/eft_gl_transfers.py @@ -0,0 +1,65 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Model to track EFT GL transfers.""" + +from datetime import datetime + +from sqlalchemy import ForeignKey + +from .base_model import BaseModel +from .db import db + + +class EFTGLTransfer(BaseModel): # pylint: disable=too-many-instance-attributes + """This class manages the file data for EFT transactions.""" + + __tablename__ = 'eft_gl_transfers' + # this mapper is used so that new and old versions of the service can be run simultaneously, + # making rolling upgrades easier + # This is used by SQLAlchemy to explicitly define which fields we're interested + # so it doesn't freak out and say it can't map the structure if other fields are present. + # This could occur from a failed deploy or during an upgrade. + # The other option is to tell SQLAlchemy to ignore differences, but that is ambiguous + # and can interfere with Alembic upgrades. + # + # NOTE: please keep mapper names in alpha-order, easier to track that way + # Exception, id is always first, _fields first + __mapper_args__ = { + 'include_properties': [ + 'id', + 'created_on', + 'invoice_id', + 'is_processed', + 'processed_on', + 'short_name_id', + 'source_gl', + 'target_gl', + 'transfer_amount', + 'transfer_type', + 'transfer_date' + ] + } + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + # Intended to be populated based on TDI17 date for GL Transfers or payment date of an invoice for distributions + transfer_date = db.Column('transfer_date', db.DateTime, nullable=False, default=datetime.now, index=True) + transfer_type = db.Column('transfer_type', db.String(50), nullable=False) + transfer_amount = db.Column(db.Numeric(19, 2), nullable=False) + source_gl = db.Column('source_gl', db.String(50), nullable=False) + target_gl = db.Column('target_gl', db.String(50), nullable=False) + is_processed = db.Column('is_processed', db.Boolean(), nullable=False, default=False) + processed_on = db.Column('processed_on', db.DateTime, nullable=True) + created_on = db.Column('created_on', db.DateTime, nullable=False, default=datetime.now) + invoice_id = db.Column(db.Integer, ForeignKey('invoices.id'), nullable=True) + short_name_id = db.Column(db.Integer, ForeignKey('eft_short_names.id'), nullable=False) diff --git a/pay-api/src/pay_api/services/__init__.py b/pay-api/src/pay_api/services/__init__.py index fc6783b91..b51afcf47 100755 --- a/pay-api/src/pay_api/services/__init__.py +++ b/pay-api/src/pay_api/services/__init__.py @@ -15,6 +15,7 @@ from .cfs_service import CFSService from .distribution_code import DistributionCode +from .eft_gl_transfer import EFTGlTransfer from .fee_schedule import FeeSchedule from .hashing import HashingService from .internal_pay_service import InternalPayService diff --git a/pay-api/src/pay_api/services/eft_gl_transfer.py b/pay-api/src/pay_api/services/eft_gl_transfer.py new file mode 100644 index 000000000..c6b8b1c4f --- /dev/null +++ b/pay-api/src/pay_api/services/eft_gl_transfer.py @@ -0,0 +1,108 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Service to manage EFT GL Transfers.""" +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + +from sqlalchemy import func + +from pay_api.models import EFTGLTransfer as EFTGLTransferModel +from pay_api.models import db + + +@dataclass +class EFTGlTransferSearch: # pylint: disable=too-many-instance-attributes + """Used for searching EFT GL Transfer records.""" + + created_on: Optional[datetime] = None + invoice_id: Optional[int] = None + is_processed: Optional[bool] = None + processed_on: Optional[datetime] = None + short_name_id: Optional[int] = None + source_gl: Optional[str] = None + target_gl: Optional[str] = None + transfer_type: Optional[str] = None + transfer_date: Optional[datetime] = None + + +class EFTGlTransfer: + """Service to manage EFT GL transfers.""" + + @staticmethod + def find_by_id(transfer_id: int) -> EFTGLTransferModel: + """Return EFT Transfers by id.""" + return EFTGLTransferModel.find_by_id(transfer_id) + + @staticmethod + def find_by_short_name_id(short_name_id: int, is_processed: bool = None) -> [EFTGLTransferModel]: + """Return EFT Transfers by short_name_id.""" + query = db.session.query(EFTGLTransferModel) \ + .filter(EFTGLTransferModel.short_name_id == short_name_id) + + if is_processed is not None: + query = query.filter(EFTGLTransferModel.is_processed == is_processed) \ + + query = query.order_by(EFTGLTransferModel.created_on.asc()) + + return query.all() + + @staticmethod + def find_by_invoice_id(invoice_id: int, is_processed: bool = None) -> [EFTGLTransferModel]: + """Return EFT Transfers by invoice_id.""" + query = db.session.query(EFTGLTransferModel) \ + .filter(EFTGLTransferModel.invoice_id == invoice_id) + + if is_processed is not None: + query = query.filter(EFTGLTransferModel.is_processed == is_processed) + + query = query.order_by(EFTGLTransferModel.created_on.asc()) + + return query.all() + + @staticmethod + def search(search_criteria: EFTGlTransferSearch = EFTGlTransferSearch()) -> [EFTGLTransferModel]: + """Return EFT Transfers by search criteria.""" + query = db.session.query(EFTGLTransferModel) + + if search_criteria.created_on: + query = query.filter(func.DATE(EFTGLTransferModel.created_on) == search_criteria.created_on.date()) + + if search_criteria.invoice_id: + query = query.filter(EFTGLTransferModel.invoice_id == search_criteria.invoice_id) + + if search_criteria.is_processed is not None: + query = query.filter(EFTGLTransferModel.is_processed == search_criteria.is_processed) + + if search_criteria.processed_on: + query = query.filter(func.DATE(EFTGLTransferModel.processed_on) == search_criteria.processed_on.date()) + + if search_criteria.short_name_id: + query = query.filter(EFTGLTransferModel.short_name_id == search_criteria.short_name_id) + + if search_criteria.source_gl: + query = query.filter(EFTGLTransferModel.source_gl == search_criteria.source_gl) + + if search_criteria.target_gl: + query = query.filter(EFTGLTransferModel.target_gl == search_criteria.target_gl) + + if search_criteria.transfer_type: + query = query.filter(EFTGLTransferModel.transfer_type == search_criteria.transfer_type) + + if search_criteria.transfer_date: + query = query.filter(func.DATE(EFTGLTransferModel.transfer_date) == search_criteria.transfer_date.date()) + + query = query.order_by(EFTGLTransferModel.created_on.asc()) + + return query.all() diff --git a/pay-api/src/pay_api/services/eft_service.py b/pay-api/src/pay_api/services/eft_service.py index 191af081b..88d4401ad 100644 --- a/pay-api/src/pay_api/services/eft_service.py +++ b/pay-api/src/pay_api/services/eft_service.py @@ -42,7 +42,10 @@ def create_invoice(self, payment_account: PaymentAccount, line_items: [PaymentLi """Return a static invoice number for direct pay.""" # Do nothing here as the invoice references will be created later for eft payment reconciliations (TDI17). - def apply_credit(self, invoice: Invoice) -> None: + def apply_credit(self, + invoice: Invoice, + payment_date: datetime = datetime.now(), + auto_save: bool = True) -> tuple: """Apply eft credit to the invoice.""" invoice_balance = invoice.total - (invoice.paid or 0) # balance before applying credits payment_account = PaymentAccount.find_by_id(invoice.payment_account_id) @@ -53,12 +56,18 @@ def apply_credit(self, invoice: Invoice) -> None: payment = self.create_payment(payment_account=payment_account, invoice=invoice_model, - payment_date=datetime.now(), - paid_amount=invoice_balance - new_invoice_balance).save() + payment_date=payment_date, + paid_amount=invoice_balance - new_invoice_balance) - self.create_invoice_reference(invoice=invoice_model, payment=payment).save() - self.create_receipt(invoice=invoice_model, payment=payment).save() - self._release_payment(invoice=invoice) + invoice_ref = self.create_invoice_reference(invoice=invoice_model, payment=payment) + receipt = self.create_receipt(invoice=invoice_model, payment=payment) + + if auto_save: + payment.save() + invoice_ref.save() + receipt.save() + + return payment, invoice_ref, receipt def complete_post_invoice(self, invoice: Invoice, invoice_reference: InvoiceReference) -> None: """Complete any post invoice activities if needed.""" diff --git a/pay-api/src/pay_api/services/payment_account.py b/pay-api/src/pay_api/services/payment_account.py index 0adef5b32..028e7cd0f 100644 --- a/pay-api/src/pay_api/services/payment_account.py +++ b/pay-api/src/pay_api/services/payment_account.py @@ -27,6 +27,7 @@ from pay_api.models import AccountFeeSchema from pay_api.models import CfsAccount as CfsAccountModel from pay_api.models import EFTCredit as EFTCreditModel +from pay_api.models import EFTCreditInvoiceLink as EFTCreditInvoiceLinkModel from pay_api.models import Invoice as InvoiceModel from pay_api.models import PaymentAccount as PaymentAccountModel from pay_api.models import PaymentAccountSchema @@ -661,7 +662,7 @@ def get_eft_credit_balance(cls, account_id: int) -> Decimal: return Decimal(result.credit_balance) if result else 0 @classmethod - def deduct_eft_credit(cls, invoice: InvoiceModel): + def deduct_eft_credit(cls, invoice: InvoiceModel, auto_save: bool = True): """Deduct EFT credit and update remaining credit records.""" eft_credits: List[EFTCreditModel] = db.session.query(EFTCreditModel) \ .filter(EFTCreditModel.remaining_amount > 0) \ @@ -675,15 +676,21 @@ def deduct_eft_credit(cls, invoice: InvoiceModel): # Deduct credits and apply to the invoice now = datetime.now() for eft_credit in eft_credits: + credit_invoice_link = EFTCreditInvoiceLinkModel( + eft_credit_id=eft_credit.id, + invoice_id=invoice.id + ) + _ = credit_invoice_link.save() if auto_save else db.session.add(credit_invoice_link) + if eft_credit.remaining_amount >= invoice_balance: # Credit covers the full invoice balance eft_credit.remaining_amount -= invoice_balance - eft_credit.save() + _ = eft_credit.save() if auto_save else db.session.add(eft_credit) invoice.payment_date = now invoice.paid = invoice.total invoice.invoice_status_code = InvoiceStatus.PAID.value - invoice.save() + _ = invoice.save() if auto_save else db.session.add(invoice) break # Credit covers partial invoice balance @@ -693,9 +700,9 @@ def deduct_eft_credit(cls, invoice: InvoiceModel): invoice.invoice_status_code = InvoiceStatus.PARTIAL.value eft_credit.remaining_amount = 0 - eft_credit.save() + _ = eft_credit if auto_save else db.session.add(eft_credit) - invoice.save() + _ = invoice if auto_save else db.session.add(invoice) @staticmethod def _calculate_activation_date(): diff --git a/pay-api/src/pay_api/utils/enums.py b/pay-api/src/pay_api/utils/enums.py index 061c0fce5..17c2ebe6f 100644 --- a/pay-api/src/pay_api/utils/enums.py +++ b/pay-api/src/pay_api/utils/enums.py @@ -267,6 +267,7 @@ class EjvFileType(Enum): DISBURSEMENT = 'DISBURSEMENT' REFUND = 'REFUND' NON_GOV_DISBURSEMENT = 'NON_GOV_DISBURSEMENT' + TRANSFER = 'TRANSFER' class PatchActions(Enum): @@ -312,6 +313,14 @@ class EFTFileLineType(Enum): TRAILER = 'TRAILER' +class EFTGlTransferType(Enum): + """EFT GL Transfer types for job processing.""" + + PAYMENT = 'PAYMENT' + REVERSAL = 'REVERSAL' + TRANSFER = 'TRANSFER' + + class MessageType(Enum): """Account Mailer Event Types.""" diff --git a/pay-api/tests/unit/models/test_eft_credit_invoice_link.py b/pay-api/tests/unit/models/test_eft_credit_invoice_link.py new file mode 100644 index 000000000..5931edd14 --- /dev/null +++ b/pay-api/tests/unit/models/test_eft_credit_invoice_link.py @@ -0,0 +1,70 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests to assure the EFT Credit invoice link model. + +Test-Suite to ensure that the EFT Credit invoice link model is working as expected. +""" + +from pay_api.models import EFTCredit, EFTCreditInvoiceLink, EFTFile, EFTShortnames, EFTTransaction +from pay_api.utils.enums import EFTFileLineType, EFTProcessStatus +from tests.utilities.base_test import factory_invoice, factory_payment_account + + +def test_eft_credit_invoice_link(session): + """Assert eft credit invoice links are stored.""" + payment_account = factory_payment_account() + payment_account.save() + + invoice = factory_invoice(payment_account=payment_account) + invoice.save() + + assert payment_account.id is not None + + eft_short_name = EFTShortnames() + eft_short_name.auth_account_id = payment_account.auth_account_id + eft_short_name.short_name = 'TESTSHORTNAME' + eft_short_name.save() + + eft_file = EFTFile() + eft_file.file_ref = 'test.txt' + eft_file.save() + + eft_transaction = EFTTransaction() + eft_transaction.file_id = eft_file.id + eft_transaction.line_number = 1 + eft_transaction.line_type = EFTFileLineType.HEADER.value + eft_transaction.status_code = EFTProcessStatus.COMPLETED.value + eft_transaction.save() + + eft_credit = EFTCredit() + eft_credit.eft_file_id = eft_file.id + eft_credit.short_name_id = eft_short_name.id + eft_credit.amount = 100.00 + eft_credit.remaining_amount = 50.00 + eft_credit.eft_transaction_id = eft_transaction.id + eft_credit.save() + + eft_credit.payment_account_id = payment_account.id + eft_credit.save() + + eft_credit_invoice_link = EFTCreditInvoiceLink() + eft_credit_invoice_link.invoice_id = invoice.id + eft_credit_invoice_link.eft_credit_id = eft_credit.id + eft_credit_invoice_link.save() + + eft_credit_invoice_link = EFTCreditInvoiceLink.find_by_id(eft_credit_invoice_link.id) + assert eft_credit_invoice_link.id is not None + assert eft_credit_invoice_link.eft_credit_id == eft_credit.id + assert eft_credit_invoice_link.invoice_id == invoice.id diff --git a/pay-api/tests/unit/models/test_eft_credits.py b/pay-api/tests/unit/models/test_eft_credits.py index 7124001ef..d63171920 100644 --- a/pay-api/tests/unit/models/test_eft_credits.py +++ b/pay-api/tests/unit/models/test_eft_credits.py @@ -19,7 +19,8 @@ from datetime import datetime from typing import List -from pay_api.models import EFTCredit, EFTFile, EFTShortnames +from pay_api.models import EFTCredit, EFTFile, EFTShortnames, EFTTransaction +from pay_api.utils.enums import EFTFileLineType, EFTProcessStatus from tests.utilities.base_test import factory_payment_account @@ -39,11 +40,19 @@ def test_eft_credits(session): eft_file.file_ref = 'test.txt' eft_file.save() + eft_transaction = EFTTransaction() + eft_transaction.file_id = eft_file.id + eft_transaction.line_number = 1 + eft_transaction.line_type = EFTFileLineType.HEADER.value + eft_transaction.status_code = EFTProcessStatus.COMPLETED.value + eft_transaction.save() + eft_credit = EFTCredit() eft_credit.eft_file_id = eft_file.id eft_credit.short_name_id = eft_short_name.id eft_credit.amount = 100.00 eft_credit.remaining_amount = 50.00 + eft_credit.eft_transaction_id = eft_transaction.id eft_credit.save() assert eft_credit.id is not None @@ -52,6 +61,7 @@ def test_eft_credits(session): assert eft_credit.created_on.date() == datetime.now().date() assert eft_credit.amount == 100.00 assert eft_credit.remaining_amount == 50.00 + assert eft_credit.eft_transaction_id == eft_transaction.id eft_credit.payment_account_id = payment_account.id eft_credit.save() diff --git a/pay-api/tests/unit/models/test_eft_gl_transfer.py b/pay-api/tests/unit/models/test_eft_gl_transfer.py new file mode 100644 index 000000000..83cec3d6e --- /dev/null +++ b/pay-api/tests/unit/models/test_eft_gl_transfer.py @@ -0,0 +1,112 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests to assure the EFT GL Transfer model. + +Test-Suite to ensure that the EFT GL Transfer model is working as expected. +""" +from datetime import datetime + +from pay_api.models.eft_gl_transfers import EFTGLTransfer as EFTGLTransferModel +from pay_api.models.eft_short_names import EFTShortnames as EFTShortnamesModel +from pay_api.utils.enums import EFTGlTransferType +from tests.utilities.base_test import factory_invoice, factory_payment, factory_payment_account + + +def create_invoice_data(): + """Create invoice seed data for test.""" + payment_account = factory_payment_account() + payment = factory_payment() + payment_account.save() + payment.save() + invoice = factory_invoice(payment_account=payment_account) + invoice.save() + assert invoice.id is not None + + return invoice + + +def create_short_name_data(): + """Create shortname seed data for test.""" + eft_short_name = EFTShortnamesModel() + eft_short_name.short_name = 'ABC' + eft_short_name.save() + + return eft_short_name + + +def test_eft_gl_transfer_defaults(session): + """Assert eft gl transfer defaults are stored.""" + eft_shortname = create_short_name_data() + eft_gl_transfer = EFTGLTransferModel() + eft_gl_transfer.transfer_amount = 125.00 + eft_gl_transfer.transfer_type = EFTGlTransferType.TRANSFER.value + eft_gl_transfer.source_gl = 'test source gl' + eft_gl_transfer.target_gl = 'test target gl' + eft_gl_transfer.short_name_id = eft_shortname.id + eft_gl_transfer.save() + + assert eft_gl_transfer.id is not None + eft_gl_transfer = EFTGLTransferModel.find_by_id(eft_gl_transfer.id) + + today = datetime.now().date() + assert eft_gl_transfer.created_on.date() == today + assert eft_gl_transfer.invoice_id is None + assert eft_gl_transfer.is_processed is False + assert eft_gl_transfer.processed_on is None + assert eft_gl_transfer.short_name_id == eft_shortname.id + assert eft_gl_transfer.source_gl == 'test source gl' + assert eft_gl_transfer.target_gl == 'test target gl' + assert eft_gl_transfer.transfer_amount == 125.00 + assert eft_gl_transfer.transfer_type == EFTGlTransferType.TRANSFER.value + assert eft_gl_transfer.transfer_date.date() == today + + +def test_eft_gl_transfer_all_attributes(session): + """Assert all eft file attributes are stored.""" + invoice = create_invoice_data() + eft_shortname = create_short_name_data() + eft_gl_transfer = EFTGLTransferModel() + + created_on = datetime(2024, 1, 1, 10, 0, 0) + transfer_date = datetime(2024, 1, 10, 8, 0) + processed_on = datetime(2024, 1, 11, 8, 0) + transfer_type = EFTGlTransferType.PAYMENT.value + transfer_amount = 125.00 + + eft_gl_transfer.created_on = created_on + eft_gl_transfer.invoice_id = invoice.id + eft_gl_transfer.is_processed = True + eft_gl_transfer.processed_on = processed_on + eft_gl_transfer.short_name_id = eft_shortname.id + eft_gl_transfer.source_gl = 'test source gl' + eft_gl_transfer.target_gl = 'test target gl' + eft_gl_transfer.transfer_type = transfer_type + eft_gl_transfer.transfer_date = transfer_date + eft_gl_transfer.transfer_amount = transfer_amount + eft_gl_transfer.save() + + assert eft_gl_transfer.id is not None + eft_gl_transfer = EFTGLTransferModel.find_by_id(eft_gl_transfer.id) + + assert eft_gl_transfer.created_on == created_on + assert eft_gl_transfer.invoice_id == invoice.id + assert eft_gl_transfer.is_processed + assert eft_gl_transfer.processed_on == processed_on + assert eft_gl_transfer.short_name_id == eft_shortname.id + assert eft_gl_transfer.source_gl == 'test source gl' + assert eft_gl_transfer.target_gl == 'test target gl' + assert eft_gl_transfer.transfer_type == EFTGlTransferType.PAYMENT.value + assert eft_gl_transfer.transfer_date == transfer_date + assert eft_gl_transfer.transfer_amount == transfer_amount diff --git a/pay-api/tests/unit/services/test_eft_gl_transfer.py b/pay-api/tests/unit/services/test_eft_gl_transfer.py new file mode 100644 index 000000000..6f1c386ea --- /dev/null +++ b/pay-api/tests/unit/services/test_eft_gl_transfer.py @@ -0,0 +1,230 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests to assure the EFT GL Transfer service. + +Test-Suite to ensure that the EFT GL Transfer service is working as expected. +""" +from datetime import datetime, timedelta + +from _decimal import Decimal + +from pay_api.models.eft_gl_transfers import EFTGLTransfer as EFTGLTransferModel +from pay_api.models.eft_short_names import EFTShortnames as EFTShortnamesModel +from pay_api.services.eft_gl_transfer import EFTGlTransfer, EFTGlTransferSearch +from pay_api.utils.enums import EFTGlTransferType +from tests.utilities.base_test import factory_invoice, factory_payment, factory_payment_account + + +def create_seed_transfer_data(short_name: str = 'ABC', invoice_id: int = None, transfer_amount: Decimal = 125.00): + """Create EFT GL Transfer seed data.""" + eft_shortname = create_short_name_data(short_name) + + eft_gl_transfer = EFTGLTransferModel() + eft_gl_transfer.transfer_type = EFTGlTransferType.TRANSFER.value + eft_gl_transfer.transfer_amount = transfer_amount + eft_gl_transfer.source_gl = 'test source gl' + eft_gl_transfer.target_gl = 'test target gl' + eft_gl_transfer.short_name_id = eft_shortname.id + eft_gl_transfer.invoice_id = invoice_id + eft_gl_transfer.save() + + return eft_shortname, eft_gl_transfer + + +def create_short_name_data(short_name: str): + """Create EFT short name seed data.""" + eft_shortname = EFTShortnamesModel() + eft_shortname.short_name = short_name + eft_shortname.save() + + return eft_shortname + + +def create_invoice_data(): + """Create invoice seed data for test.""" + payment_account = factory_payment_account() + payment = factory_payment() + payment_account.save() + payment.save() + invoice = factory_invoice(payment_account=payment_account) + invoice.save() + assert invoice.id is not None + + return invoice + + +def assert_transfers(transfer_1: EFTGlTransfer, transfer_2: EFTGlTransfer): + """Assert equality between two EFT GL Transfer models.""" + assert transfer_1.id == transfer_2.id + assert transfer_1.created_on == transfer_2.created_on + assert transfer_1.invoice_id == transfer_2.invoice_id + assert transfer_1.is_processed == transfer_2.is_processed + assert transfer_1.processed_on == transfer_2.processed_on + assert transfer_1.short_name_id == transfer_2.short_name_id + assert transfer_1.source_gl == transfer_2.source_gl + assert transfer_1.target_gl == transfer_2.target_gl + assert transfer_1.transfer_type == transfer_2.transfer_type + assert transfer_1.transfer_date == transfer_2.transfer_date + assert transfer_1.transfer_amount == transfer_2.transfer_amount + + +def test_find_by_transfer_id(session): + """Test find by transfer id.""" + eft_shortname, eft_gl_transfer = create_seed_transfer_data() + + transfer = EFTGlTransfer.find_by_id(eft_gl_transfer.id) + + assert_transfers(transfer, eft_gl_transfer) + + +def test_find_by_short_name_id(session): + """Test find by short_name_id.""" + eft_shortname, eft_gl_transfer = create_seed_transfer_data() + + # Assert find by short name returns the right record + transfers = EFTGlTransfer.find_by_short_name_id(eft_shortname.id) + assert transfers + assert len(transfers) == 1 + assert_transfers(transfers[0], eft_gl_transfer) + + # Assert find by short name properly returns nothing + transfers = EFTGlTransfer.find_by_short_name_id(9999) + assert not transfers + + +def test_find_by_invoice_id(session): + """Test find by invoice_id.""" + invoice = create_invoice_data() + eft_shortname, eft_gl_transfer = create_seed_transfer_data(invoice_id=invoice.id) + + # Assert find by invoice returns the right record + transfers = EFTGlTransfer.find_by_invoice_id(invoice.id) + assert transfers + assert len(transfers) == 1 + assert_transfers(transfers[0], eft_gl_transfer) + + # Assert find by invoice properly returns nothing + transfers = EFTGlTransfer.find_by_invoice_id(9999) + assert not transfers + + +def test_search_transfers(session): + """Test EFT GL Transfers search.""" + # Confirm search all (no criteria) works + transfers = EFTGlTransfer.search() + assert not transfers + + # Create transfer data for testing the search + invoice_1 = create_invoice_data() + eft_shortname_1, eft_gl_transfer_1 = create_seed_transfer_data(short_name='ABC', + invoice_id=invoice_1.id, + transfer_amount=150.00) + + invoice_2 = create_invoice_data() + eft_shortname_2, eft_gl_transfer_2 = create_seed_transfer_data(short_name='DEF', + invoice_id=invoice_2.id, + transfer_amount=300.25) + eft_gl_transfer_2.is_processed = True + eft_gl_transfer_2.processed_on = datetime.now() + eft_gl_transfer_2.transfer_type = EFTGlTransferType.PAYMENT.value + eft_gl_transfer_2.save() + + eft_shortname_3, eft_gl_transfer_3 = create_seed_transfer_data(short_name='GHI') + + transfers = EFTGlTransfer.search() + assert transfers + assert len(transfers) == 3 + + assert_transfers(transfers[0], eft_gl_transfer_1) + assert_transfers(transfers[1], eft_gl_transfer_2) + assert_transfers(transfers[2], eft_gl_transfer_3) + + # Assert created_on search criteria + transfers = EFTGlTransfer.search(EFTGlTransferSearch(created_on=datetime.now())) + assert transfers + assert len(transfers) == 3 + + transfers = EFTGlTransfer.search(EFTGlTransferSearch(created_on=datetime.now() + timedelta(days=1))) + assert not transfers + + # Assert invoice_id search criteria + transfers = EFTGlTransfer.search(EFTGlTransferSearch(invoice_id=invoice_1.id)) + assert transfers + assert len(transfers) == 1 + assert_transfers(transfers[0], eft_gl_transfer_1) + + # Assert is_processed True search criteria + transfers = EFTGlTransfer.search(EFTGlTransferSearch(is_processed=True)) + assert transfers + assert len(transfers) == 1 + assert_transfers(transfers[0], eft_gl_transfer_2) + + # Assert is_processed False search criteria + transfers = EFTGlTransfer.search(EFTGlTransferSearch(is_processed=False)) + assert transfers + assert len(transfers) == 2 + assert_transfers(transfers[0], eft_gl_transfer_1) + assert_transfers(transfers[1], eft_gl_transfer_3) + + # Assert processed_on search criteria + transfers = EFTGlTransfer.search(EFTGlTransferSearch(processed_on=datetime.now())) + assert transfers + assert len(transfers) == 1 + assert_transfers(transfers[0], eft_gl_transfer_2) + + transfers = EFTGlTransfer.search(EFTGlTransferSearch(processed_on=datetime.now() + timedelta(days=1))) + assert not transfers + + # Assert short_name_id search criteria + transfers = EFTGlTransfer.search(EFTGlTransferSearch(short_name_id=eft_shortname_3.id)) + assert transfers + assert len(transfers) == 1 + assert_transfers(transfers[0], eft_gl_transfer_3) + + # Assert source_gl search criteria + transfers = EFTGlTransfer.search(EFTGlTransferSearch(source_gl='test source gl')) + assert transfers + assert len(transfers) == 3 + + transfers = EFTGlTransfer.search(EFTGlTransferSearch(source_gl='nothing')) + assert not transfers + + # Assert target_gl search criteria + transfers = EFTGlTransfer.search(EFTGlTransferSearch(target_gl='test target gl')) + assert transfers + assert len(transfers) == 3 + + transfers = EFTGlTransfer.search(EFTGlTransferSearch(target_gl='nothing')) + assert not transfers + + # Assert transfer_type search criteria + transfers = EFTGlTransfer.search(EFTGlTransferSearch(transfer_type=EFTGlTransferType.TRANSFER.value)) + assert transfers + assert len(transfers) == 2 + assert_transfers(transfers[0], eft_gl_transfer_1) + assert_transfers(transfers[1], eft_gl_transfer_3) + + transfers = EFTGlTransfer.search(EFTGlTransferSearch(transfer_type=EFTGlTransferType.PAYMENT.value)) + assert transfers + assert len(transfers) == 1 + assert_transfers(transfers[0], eft_gl_transfer_2) + + # Assert transfer date search criteria + transfers = EFTGlTransfer.search(EFTGlTransferSearch(transfer_date=datetime.now())) + assert transfers + assert len(transfers) == 3 + + transfers = EFTGlTransfer.search(EFTGlTransferSearch(transfer_date=datetime.now() + timedelta(days=1))) + assert not transfers