diff --git a/pay-api/migrations/versions/2024_09_18_67407611eec8_21539_shortname_refund_history.py b/pay-api/migrations/versions/2024_09_18_67407611eec8_21539_shortname_refund_history.py new file mode 100644 index 000000000..9b21366e5 --- /dev/null +++ b/pay-api/migrations/versions/2024_09_18_67407611eec8_21539_shortname_refund_history.py @@ -0,0 +1,34 @@ +"""EFT Short name history short name refund. + +Revision ID: 67407611eec8 +Revises: 423a9f909079 +Create Date: 2024-09-18 10:20:15.689980 + +""" +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 = '67407611eec8' +down_revision = '423a9f909079' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table('eft_short_names_historical', schema=None) as batch_op: + batch_op.add_column(sa.Column('eft_refund_id', sa.Integer(), nullable=True)) + batch_op.create_index(batch_op.f('ix_eft_short_names_historical_eft_refund_id'), ['eft_refund_id'], unique=False) + batch_op.create_foreign_key('eft_short_names_historical_eft_refund_id_fkey', 'eft_refunds', ['eft_refund_id'], ['id']) + + +def downgrade(): + with op.batch_alter_table('eft_short_names_historical', schema=None) as batch_op: + batch_op.drop_constraint('eft_short_names_historical_eft_refund_id_fkey', type_='foreignkey') + batch_op.drop_index(batch_op.f('ix_eft_short_names_historical_eft_refund_id')) + batch_op.drop_column('eft_refund_id') diff --git a/pay-api/migrations/versions/2024_09_19_29f59e6f147b_EFT_Refunds.py b/pay-api/migrations/versions/2024_09_19_29f59e6f147b_EFT_Refunds.py new file mode 100644 index 000000000..b99f571a2 --- /dev/null +++ b/pay-api/migrations/versions/2024_09_19_29f59e6f147b_EFT_Refunds.py @@ -0,0 +1,30 @@ +"""EFT Refunds additional columns + +Revision ID: 29f59e6f147b +Revises: 67407611eec8 +Create Date: 2024-09-19 16:09:53.120704 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. + +revision = '29f59e6f147b' +down_revision = '67407611eec8' +branch_labels = None +depends_on = None + + +def upgrade(): + + with op.batch_alter_table('eft_refunds', schema=None) as batch_op: + batch_op.add_column(sa.Column('decline_reason', sa.String(), nullable=True)) + batch_op.add_column(sa.Column('created_by', sa.String(length=100), nullable=True)) + + +def downgrade(): + with op.batch_alter_table('eft_refunds', schema=None) as batch_op: + batch_op.drop_column('created_by') + batch_op.drop_column('decline_reason') diff --git a/pay-api/src/pay_api/models/eft_refund.py b/pay-api/src/pay_api/models/eft_refund.py index 6ffb1cbc6..880c14460 100644 --- a/pay-api/src/pay_api/models/eft_refund.py +++ b/pay-api/src/pay_api/models/eft_refund.py @@ -39,7 +39,9 @@ class EFTRefund(BaseModel): # pylint: disable=too-many-instance-attributes 'auth_account_id', 'cas_supplier_number', 'comment', + 'created_by', 'created_on', + 'decline_reason', 'id', 'refund_amount', 'refund_email', @@ -53,6 +55,8 @@ class EFTRefund(BaseModel): # pylint: disable=too-many-instance-attributes cas_supplier_number = db.Column(db.String(), nullable=False) comment = db.Column(db.String(), nullable=False) + decline_reason = db.Column(db.String(), nullable=True) + created_by = db.Column('created_by', db.String(100), nullable=True) created_on = db.Column('created_on', db.DateTime, nullable=False, default=lambda: datetime.now(tz=timezone.utc)) id = db.Column(db.Integer, primary_key=True, autoincrement=True) refund_amount = db.Column(db.Numeric(), nullable=False) diff --git a/pay-api/src/pay_api/models/eft_short_names_historical.py b/pay-api/src/pay_api/models/eft_short_names_historical.py index ffc55d6bd..e87dfd624 100644 --- a/pay-api/src/pay_api/models/eft_short_names_historical.py +++ b/pay-api/src/pay_api/models/eft_short_names_historical.py @@ -45,6 +45,7 @@ class EFTShortnamesHistorical(BaseModel): # pylint:disable=too-many-instance-at 'created_on', 'credit_balance', 'description', + 'eft_refund_id', 'hidden', 'invoice_id', 'is_processing', @@ -61,6 +62,7 @@ class EFTShortnamesHistorical(BaseModel): # pylint:disable=too-many-instance-at created_by = db.Column(db.String, nullable=True) created_on = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(tz=timezone.utc)) credit_balance = db.Column(db.Numeric(19, 2), nullable=False) + eft_refund_id = db.Column(db.Integer, ForeignKey('eft_refunds.id'), nullable=True, index=True) hidden = db.Column(db.Boolean(), nullable=False, default=False, index=True) invoice_id = db.Column(db.Integer, ForeignKey('invoices.id'), nullable=True, index=True) is_processing = db.Column(db.Boolean(), nullable=False, default=False) @@ -76,6 +78,11 @@ def find_by_related_group_link_id(cls, group_link_id: int) -> Self: """Find historical records by related EFT Credit Invoice Link group id.""" return cls.query.filter_by(related_group_link_id=group_link_id).one_or_none() + @classmethod + def find_by_eft_refund_id(cls, eft_refund_id: int) -> Self: + """Find historical records by EFT refund id.""" + return cls.query.filter_by(eft_refund_id=eft_refund_id).one_or_none() + @define class EFTShortnameHistorySchema: # pylint: disable=too-few-public-methods diff --git a/pay-api/src/pay_api/services/eft_service.py b/pay-api/src/pay_api/services/eft_service.py index ecd9dc2e1..a4835caf8 100644 --- a/pay-api/src/pay_api/services/eft_service.py +++ b/pay-api/src/pay_api/services/eft_service.py @@ -162,15 +162,23 @@ def create_shortname_refund(request: Dict[str, str], **kwargs) -> Dict[str, str] current_app.logger.debug(f'Starting shortname refund : {shortname_id}') - refund = EftService._create_refund_model(request, shortname_id, amount, comment) + refund = EftService._create_refund_model(request, shortname_id, amount, comment).flush() EftService._refund_eft_credits(int(shortname_id), amount) + EFTHistoryService.create_shortname_refund( + EFTHistory(short_name_id=shortname_id, + amount=amount, + credit_balance=EFTCreditModel.get_eft_credit_balance(int(shortname_id)), + eft_refund_id=refund.id, + is_processing=False, + hidden=False)).flush() + recipients = EFTRefundEmailList.find_all_emails() subject = f'Pending Refund Request for Short Name {shortname}' html_body = _render_shortname_details_body(shortname, amount, comment, shortname_id) send_email(recipients, subject, html_body, **kwargs) - refund.save() + db.session.commit() @staticmethod def apply_payment_action(short_name_id: int, auth_account_id: str): @@ -574,7 +582,7 @@ def _refund_eft_credits(shortname_id: int, amount: str): credit.remaining_amount -= deduction refund_amount -= deduction - credit.save() + credit.flush() @staticmethod def _create_refund_model(request: Dict[str, str], diff --git a/pay-api/src/pay_api/services/eft_short_name_historical.py b/pay-api/src/pay_api/services/eft_short_name_historical.py index 0196a232a..6092fa5f5 100644 --- a/pay-api/src/pay_api/services/eft_short_name_historical.py +++ b/pay-api/src/pay_api/services/eft_short_name_historical.py @@ -42,6 +42,7 @@ class EFTShortnameHistory: # pylint: disable=too-many-instance-attributes hidden: Optional[bool] = False is_processing: Optional[bool] = False invoice_id: Optional[int] = None + eft_refund_id: Optional[int] = None @dataclass @@ -124,6 +125,22 @@ def create_invoice_refund(history: EFTShortnameHistory, **kwargs) -> EFTShortnam transaction_type=EFTHistoricalTypes.INVOICE_REFUND.value ) + @staticmethod + @user_context + def create_shortname_refund(history: EFTShortnameHistory, **kwargs) -> EFTShortnamesHistoricalModel: + """Create EFT Short name refund historical record.""" + return EFTShortnamesHistoricalModel( + amount=history.amount, + created_by=kwargs['user'].user_name, + credit_balance=history.credit_balance, + hidden=history.hidden, + is_processing=history.is_processing, + short_name_id=history.short_name_id, + eft_refund_id=history.eft_refund_id, + transaction_date=EFTShortnameHistorical.transaction_date_now(), + transaction_type=EFTHistoricalTypes.SN_REFUND_PENDING_APPROVAL.value + ) + @staticmethod def transaction_date_now() -> datetime: """Construct transaction datetime using the utc timezone.""" diff --git a/pay-api/src/pay_api/utils/enums.py b/pay-api/src/pay_api/utils/enums.py index 4e3d5d34c..191441394 100644 --- a/pay-api/src/pay_api/utils/enums.py +++ b/pay-api/src/pay_api/utils/enums.py @@ -387,6 +387,11 @@ class EFTHistoricalTypes(Enum): STATEMENT_PAID = 'STATEMENT_PAID' STATEMENT_REVERSE = 'STATEMENT_REVERSE' + # Short name refund statuses + SN_REFUND_PENDING_APPROVAL = 'SN_REFUND_PENDING_APPROVAL' + SN_REFUND_APPROVED = 'SN_REFUND_APPROVED' + SN_REFUND_REJECTED = 'SN_REFUND_REJECTED' + class PaymentDetailsGlStatus(Enum): """Payment details GL status.""" diff --git a/pay-api/src/pay_api/version.py b/pay-api/src/pay_api/version.py index 15af0e50f..cf61caa1c 100644 --- a/pay-api/src/pay_api/version.py +++ b/pay-api/src/pay_api/version.py @@ -22,4 +22,4 @@ Development release segment: .devN """ -__version__ = '1.22.2' # pylint: disable=invalid-name +__version__ = '1.22.3' # pylint: disable=invalid-name diff --git a/pay-api/tests/unit/api/test_eft_short_names.py b/pay-api/tests/unit/api/test_eft_short_names.py index 7fdc69693..a39e099d0 100755 --- a/pay-api/tests/unit/api/test_eft_short_names.py +++ b/pay-api/tests/unit/api/test_eft_short_names.py @@ -20,23 +20,25 @@ import json from datetime import datetime, timezone from decimal import Decimal +from unittest.mock import patch from pay_api.models import EFTCredit as EFTCreditModel from pay_api.models import EFTCreditInvoiceLink as EFTCreditInvoiceModel from pay_api.models import EFTFile as EFTFileModel from pay_api.models import EFTShortnameLinks as EFTShortnameLinksModel from pay_api.models import EFTShortnames as EFTShortnamesModel +from pay_api.models import EFTShortnamesHistorical as EFTShortnamesHistoryModel from pay_api.models import EFTTransaction as EFTTransactionModel from pay_api.models import PaymentAccount as PaymentAccountModel from pay_api.models.eft_refund import EFTRefund as EFTRefundModel -from pay_api.services.eft_service import EftService from pay_api.utils.enums import ( - EFTCreditInvoiceStatus, EFTFileLineType, EFTProcessStatus, EFTShortnameRefundStatus, EFTShortnameStatus, - EFTShortnameType, InvoiceStatus, PaymentMethod, Role, StatementFrequency) + EFTCreditInvoiceStatus, EFTFileLineType, EFTHistoricalTypes, EFTProcessStatus, EFTShortnameRefundStatus, + EFTShortnameStatus, EFTShortnameType, InvoiceStatus, PaymentMethod, Role, StatementFrequency) from pay_api.utils.errors import Error from tests.utilities.base_test import ( - factory_eft_file, factory_eft_shortname, factory_eft_shortname_link, factory_invoice, factory_payment_account, - factory_statement, factory_statement_invoices, factory_statement_settings, get_claims, token_header) + factory_eft_credit, factory_eft_file, factory_eft_shortname, factory_eft_shortname_link, factory_invoice, + factory_payment_account, factory_statement, factory_statement_invoices, factory_statement_settings, get_claims, + token_header) def test_create_eft_short_name_link(session, client, jwt, app): @@ -897,28 +899,44 @@ def test_search_eft_short_names(session, client, jwt, app): data_dict['single-linked']['statement_summary'][0]) -def test_post_shortname_refund_success(client, mocker, jwt, app): +def test_post_shortname_refund_success(db, session, client, jwt, app): """Test successful creation of a shortname refund.""" token = jwt.create_jwt(get_claims(roles=[Role.EFT_REFUND.value]), token_header) headers = {'Authorization': f'Bearer {token}', 'content-type': 'application/json'} - mock_create_shortname_refund = mocker.patch.object(EftService, 'create_shortname_refund') - mock_create_shortname_refund.return_value = {'refundId': '12345'} + payment_account = factory_payment_account(payment_method_code=PaymentMethod.EFT.value, + auth_account_id='1234').save() + eft_file = factory_eft_file().save() + short_name = factory_eft_shortname(short_name='TESTSHORTNAME').save() + factory_eft_credit(eft_file_id=eft_file.id, short_name_id=short_name.id, amount=100, remaining_amount=100).save() data = { - 'shortNameId': '12345', - 'authAccountId': '123', - 'refundAmount': 100.00, - 'casSupplierNum': 'CAS123', - 'refundEmail': 'test@example.com', - 'comment': 'Refund for overpayment' - } - - rv = client.post('/api/v1/eft-shortnames/shortname-refund', headers=headers, json=data) - - assert rv.status_code == 202 - assert rv.json == {'refundId': '12345'} - mock_create_shortname_refund.assert_called_once_with(data) + 'shortNameId': short_name.id, + 'authAccountId': payment_account.auth_account_id, + 'refundAmount': 100.00, + 'casSupplierNum': 'CAS123', + 'refundEmail': 'test@example.com', + 'comment': 'Refund for overpayment' + } + with patch('pay_api.services.eft_service.send_email') as mock_email: + rv = client.post('/api/v1/eft-shortnames/shortname-refund', headers=headers, json=data) + assert rv.status_code == 202 + mock_email.assert_called_once() + + eft_refund = db.session.query(EFTRefundModel).one_or_none() + assert eft_refund.id is not None + assert eft_refund.short_name_id == short_name.id + assert eft_refund.refund_amount == 100.00 + assert eft_refund.cas_supplier_number == 'CAS123' + assert eft_refund.refund_email == 'test@example.com' + assert eft_refund.comment == 'Refund for overpayment' + + history_record = db.session.query(EFTShortnamesHistoryModel).one_or_none() + assert history_record is not None + assert history_record.amount == 100 + assert history_record.eft_refund_id == eft_refund.id + assert history_record.credit_balance == 0 + assert history_record.transaction_type == EFTHistoricalTypes.SN_REFUND_PENDING_APPROVAL.value def test_post_shortname_refund_invalid_request(client, mocker, jwt, app): diff --git a/pay-api/tests/unit/models/test_eft_refund.py b/pay-api/tests/unit/models/test_eft_refund.py index a5baef4e6..37b8bee59 100644 --- a/pay-api/tests/unit/models/test_eft_refund.py +++ b/pay-api/tests/unit/models/test_eft_refund.py @@ -58,26 +58,26 @@ def test_eft_refund_defaults(session): def test_eft_refund_all_attributes(session): """Assert all EFT refund attributes are stored.""" # Ensure the required entry exists in the related table - short_name = factory_eft_shortname(short_name='Test Short Name') - db.session.add(short_name) - db.session.commit() - short_name_id = short_name.id - + short_name = factory_eft_shortname(short_name='Test Short Name').save() refund_amount = 150.00 cas_supplier_number = 'SUP654321' refund_email = 'updated@example.com' comment = 'Updated comment' status = 'COMPLETED' + created_by = 'user111' + decline_reason = 'Decline reason comment' updated_by = 'user123' updated_by_name = 'User Name' eft_refund = EFTRefundModel( - short_name_id=short_name_id, + short_name_id=short_name.id, refund_amount=refund_amount, cas_supplier_number=cas_supplier_number, refund_email=refund_email, comment=comment, + decline_reason=decline_reason, status=status, + created_by=created_by, updated_by=updated_by, updated_by_name=updated_by_name, ) @@ -86,11 +86,13 @@ def test_eft_refund_all_attributes(session): eft_refund = db.session.query(EFTRefundModel).filter(EFTRefundModel.id == eft_refund.id).one_or_none() assert eft_refund is not None - assert eft_refund.short_name_id == short_name_id + assert eft_refund.short_name_id == short_name.id assert eft_refund.refund_amount == refund_amount assert eft_refund.cas_supplier_number == cas_supplier_number assert eft_refund.refund_email == refund_email assert eft_refund.comment == comment + assert eft_refund.decline_reason == decline_reason assert eft_refund.status == status + assert eft_refund.created_by == created_by assert eft_refund.updated_by == updated_by assert eft_refund.updated_by_name == updated_by_name diff --git a/pay-api/tests/unit/services/test_eft_short_name_historical.py b/pay-api/tests/unit/services/test_eft_short_name_historical.py index cbdbce74b..e37e48f0b 100644 --- a/pay-api/tests/unit/services/test_eft_short_name_historical.py +++ b/pay-api/tests/unit/services/test_eft_short_name_historical.py @@ -22,8 +22,9 @@ from pay_api.services.eft_short_name_historical import EFTShortnameHistorical as EFTShortnameHistoryService from pay_api.services.eft_short_name_historical import EFTShortnameHistory -from pay_api.utils.enums import EFTHistoricalTypes -from tests.utilities.base_test import factory_eft_shortname, factory_payment_account +from pay_api.utils.enums import EFTHistoricalTypes, InvoiceStatus, PaymentMethod +from tests.utilities.base_test import ( + factory_eft_refund, factory_eft_shortname, factory_invoice, factory_payment_account) def setup_test_data(): @@ -60,6 +61,8 @@ def test_create_funds_received(session): assert historical_record.payment_account_id is None assert historical_record.related_group_link_id is None assert historical_record.short_name_id == short_name.id + assert historical_record.eft_refund_id is None + assert historical_record.invoice_id is None assert historical_record.statement_number is None assert historical_record.transaction_date.replace(microsecond=0) == transaction_date assert historical_record.transaction_type == EFTHistoricalTypes.FUNDS_RECEIVED.value @@ -90,6 +93,8 @@ def test_create_statement_paid(session, staff_user_mock): assert historical_record.payment_account_id == payment_account.id assert historical_record.related_group_link_id == 1 assert historical_record.short_name_id == short_name.id + assert historical_record.eft_refund_id is None + assert historical_record.invoice_id is None assert historical_record.statement_number == 1234567 assert historical_record.transaction_date == transaction_date assert historical_record.transaction_type == EFTHistoricalTypes.STATEMENT_PAID.value @@ -121,6 +126,76 @@ def test_create_statement_reverse(session, staff_user_mock): assert historical_record.payment_account_id == payment_account.id assert historical_record.related_group_link_id == 1 assert historical_record.short_name_id == short_name.id + assert historical_record.eft_refund_id is None + assert historical_record.invoice_id is None assert historical_record.statement_number == 1234567 assert historical_record.transaction_date == transaction_date assert historical_record.transaction_type == EFTHistoricalTypes.STATEMENT_REVERSE.value + + +def test_create_invoice_refund(session, staff_user_mock): + """Test create short name invoice refund history.""" + transaction_date = datetime(2024, 7, 31, 0, 0, 0) + with freeze_time(transaction_date): + payment_account, short_name = setup_test_data() + invoice = factory_invoice(payment_account, payment_method_code=PaymentMethod.EFT.value, + status_code=InvoiceStatus.APPROVED.value, + total=50).save() + history = EFTShortnameHistory( + amount=151.50, + credit_balance=300, + payment_account_id=payment_account.id, + short_name_id=short_name.id, + invoice_id=invoice.id, + statement_number=1234567, + related_group_link_id=1 + ) + historical_record = EFTShortnameHistoryService.create_invoice_refund(history) + historical_record.save() + assert historical_record.id is not None + assert historical_record.amount == 151.50 + assert historical_record.created_on is not None + assert historical_record.created_by == 'STAFF USER' + assert historical_record.credit_balance == 300 + assert not historical_record.hidden + assert not historical_record.is_processing + assert historical_record.payment_account_id == payment_account.id + assert historical_record.related_group_link_id == 1 + assert historical_record.short_name_id == short_name.id + assert historical_record.eft_refund_id is None + assert historical_record.invoice_id == invoice.id + assert historical_record.statement_number == 1234567 + assert historical_record.transaction_date == transaction_date + assert historical_record.transaction_type == EFTHistoricalTypes.INVOICE_REFUND.value + + +def test_create_short_name_refund(session, staff_user_mock): + """Test create short name refund history.""" + transaction_date = datetime(2024, 7, 31, 0, 0, 0) + with freeze_time(transaction_date): + payment_account, short_name = setup_test_data() + eft_refund = factory_eft_refund(short_name.id, refund_amount=100).save() + history = EFTShortnameHistory( + amount=151.50, + credit_balance=300, + short_name_id=short_name.id, + eft_refund_id=eft_refund.id, + statement_number=1234567 + ) + historical_record = EFTShortnameHistoryService.create_shortname_refund(history) + historical_record.save() + assert historical_record.id is not None + assert historical_record.amount == 151.50 + assert historical_record.created_on is not None + assert historical_record.created_by == 'STAFF USER' + assert historical_record.credit_balance == 300 + assert not historical_record.hidden + assert not historical_record.is_processing + assert historical_record.payment_account_id is None + assert historical_record.related_group_link_id is None + assert historical_record.short_name_id == short_name.id + assert historical_record.statement_number is None + assert historical_record.eft_refund_id == eft_refund.id + assert historical_record.invoice_id is None + assert historical_record.transaction_date == transaction_date + assert historical_record.transaction_type == EFTHistoricalTypes.SN_REFUND_PENDING_APPROVAL.value diff --git a/pay-api/tests/utilities/base_test.py b/pay-api/tests/utilities/base_test.py index 3cb0a784e..ad7e1db7f 100644 --- a/pay-api/tests/utilities/base_test.py +++ b/pay-api/tests/utilities/base_test.py @@ -25,7 +25,7 @@ from faker import Faker from pay_api.models import ( - CfsAccount, Comment, DistributionCode, DistributionCodeLink, EFTCredit, EFTCreditInvoiceLink, EFTFile, + CfsAccount, Comment, DistributionCode, DistributionCodeLink, EFTCredit, EFTCreditInvoiceLink, EFTFile, EFTRefund, EFTShortnameLinks, EFTShortnames, Invoice, InvoiceReference, NonSufficientFunds, Payment, PaymentAccount, PaymentLineItem, PaymentTransaction, Receipt, RoutingSlip, Statement, StatementInvoices, StatementSettings) from pay_api.utils.constants import DT_SHORT_FORMAT @@ -931,6 +931,18 @@ def factory_eft_credit(eft_file_id, short_name_id, amount=10.00, remaining_amoun ) +def factory_eft_refund(short_name_id, refund_amount, cas_supplier_number='1234567', + refund_email='test@test.com', comment='test comment'): + """Return an EFT Refund.""" + return EFTRefund( + short_name_id=short_name_id, + refund_amount=refund_amount, + cas_supplier_number=cas_supplier_number, + refund_email=refund_email, + comment=comment + ) + + def factory_eft_credit_invoice_link(eft_credit_id, invoice_id, status_code, amount=1, link_group_id=None): """Return an EFT Credit invoice link.""" return EFTCreditInvoiceLink(eft_credit_id=eft_credit_id,