diff --git a/backend/hct_mis_api/apps/core/tests/test_utils.py b/backend/hct_mis_api/apps/core/tests/test_utils.py index 74ca8ab582..7b0f848b0a 100644 --- a/backend/hct_mis_api/apps/core/tests/test_utils.py +++ b/backend/hct_mis_api/apps/core/tests/test_utils.py @@ -19,7 +19,7 @@ def test_get_count_and_percentage(self) -> None: def test_get_payment_delivered_quantity_status_and_value(self) -> None: with self.assertRaisesMessage(Exception, "Invalid delivered quantity"): - get_payment_delivered_quantity_status_and_value(None, Decimal(10.00)) # type: ignore + get_payment_delivered_quantity_status_and_value(None, Decimal(10.00)) with self.assertRaisesMessage(Exception, "Invalid delivered quantity"): get_payment_delivered_quantity_status_and_value("", Decimal(10.00)) self.assertEqual( diff --git a/backend/hct_mis_api/apps/payment/models.py b/backend/hct_mis_api/apps/payment/models.py index 900b000798..dfe67acece 100644 --- a/backend/hct_mis_api/apps/payment/models.py +++ b/backend/hct_mis_api/apps/payment/models.py @@ -923,7 +923,11 @@ def is_reconciled(self) -> bool: return ( self.eligible_payments.exclude( - status__in=[GenericPayment.STATUS_PENDING, GenericPayment.STATUS_SENT_TO_PG] + status__in=[ + GenericPayment.STATUS_PENDING, + GenericPayment.STATUS_SENT_TO_PG, + GenericPayment.STATUS_SENT_TO_FSP, + ] ).count() == self.eligible_payments.count() ) diff --git a/backend/hct_mis_api/apps/payment/services/payment_gateway.py b/backend/hct_mis_api/apps/payment/services/payment_gateway.py index 46d2724ee2..aa5f20e737 100644 --- a/backend/hct_mis_api/apps/payment/services/payment_gateway.py +++ b/backend/hct_mis_api/apps/payment/services/payment_gateway.py @@ -150,10 +150,9 @@ class PaymentRecordData(FlexibleArgumentsDataclassMixin): record_code: str parent: str status: str - hope_status: str auth_code: str - payout_amount: float fsp_code: str + payout_amount: Optional[float] = None message: Optional[str] = None def get_hope_status(self, entitlement_quantity: Decimal) -> str: @@ -163,9 +162,8 @@ def get_transferred_status_based_on_delivery_amount() -> str: self.payout_amount, entitlement_quantity ) except Exception: - raise PaymentGatewayAPI.PaymentGatewayAPIException( - f"Invalid delivered_quantity {self.payout_amount} for Payment {self.remote_id}" - ) + logger.error(f"Invalid delivered_quantity {self.payout_amount} for Payment {self.remote_id}") + _hope_status = Payment.STATUS_ERROR return _hope_status mapping = { @@ -180,7 +178,8 @@ def get_transferred_status_based_on_delivery_amount() -> str: hope_status = mapping.get(self.status) if not hope_status: - raise PaymentGatewayAPI.PaymentGatewayAPIException(f"Invalid Payment status: {self.status}") + logger.error(f"Invalid Payment status: {self.status}") + hope_status = Payment.STATUS_ERROR return hope_status() if callable(hope_status) else hope_status @@ -435,8 +434,16 @@ def update_payment( _payment.fsp_auth_code = matching_pg_payment.auth_code update_fields = ["status", "status_date", "fsp_auth_code"] - if _payment.status not in Payment.ALLOW_CREATE_VERIFICATION and matching_pg_payment.message: - _payment.reason_for_unsuccessful_payment = matching_pg_payment.message + if _payment.status in [ + Payment.STATUS_ERROR, + Payment.STATUS_MANUALLY_CANCELLED, + ]: + if matching_pg_payment.message: + _payment.reason_for_unsuccessful_payment = matching_pg_payment.message + elif matching_pg_payment.payout_amount: + _payment.reason_for_unsuccessful_payment = f"Delivered amount: {matching_pg_payment.payout_amount}" + else: + _payment.reason_for_unsuccessful_payment = "Unknown error" update_fields.append("reason_for_unsuccessful_payment") delivered_quantity = matching_pg_payment.payout_amount @@ -449,7 +456,7 @@ def update_payment( try: _payment.delivered_quantity = to_decimal(delivered_quantity) _payment.delivered_quantity_usd = get_quantity_in_usd( - amount=Decimal(delivered_quantity), + amount=Decimal(delivered_quantity), # type: ignore currency=_payment_plan.currency, exchange_rate=Decimal(_exchange_rate), currency_exchange_date=_payment_plan.currency_exchange_date, diff --git a/backend/hct_mis_api/apps/payment/tests/test_payment_gateway_service.py b/backend/hct_mis_api/apps/payment/tests/test_payment_gateway_service.py index 6f80290eea..70c606fdeb 100644 --- a/backend/hct_mis_api/apps/payment/tests/test_payment_gateway_service.py +++ b/backend/hct_mis_api/apps/payment/tests/test_payment_gateway_service.py @@ -33,7 +33,6 @@ ) from hct_mis_api.apps.payment.services.payment_gateway import ( AddRecordsResponseData, - PaymentGatewayAPI, PaymentGatewayService, PaymentInstructionStatus, PaymentRecordData, @@ -131,7 +130,6 @@ def test_sync_records_for_split( record_code="1", parent="1", status="TRANSFERRED_TO_BENEFICIARY", - hope_status=Payment.STATUS_DISTRIBUTION_SUCCESS, auth_code="1", payout_amount=float(self.payments[0].entitlement_quantity), fsp_code="1", @@ -158,7 +156,6 @@ def test_sync_records_for_split( record_code="2", parent="2", status="ERROR", - hope_status=Payment.STATUS_ERROR, auth_code="2", payout_amount=0.0, fsp_code="2", @@ -188,6 +185,24 @@ def test_sync_records_for_split( def test_sync_records( self, get_quantity_in_usd_mock: Any, get_records_for_payment_instruction_mock: Any, get_exchange_rate_mock: Any ) -> None: + collector = IndividualFactory(household=None) + hoh = IndividualFactory(household=None) + hh = HouseholdFactory(head_of_household=hoh) + IndividualRoleInHouseholdFactory(household=hh, individual=hoh, role=ROLE_PRIMARY) + IndividualFactory.create_batch(2, household=hh) + self.payments.append( + PaymentFactory( + parent=self.pp, + household=hh, + status=Payment.STATUS_PENDING, + currency="PLN", + collector=collector, + delivered_quantity=None, + delivered_quantity_usd=None, + financial_service_provider=self.pg_fsp, + ) + ) + self.dm.sent_to_payment_gateway = True self.dm.save() @@ -199,10 +214,74 @@ def test_sync_records( modified="2023-10-11", record_code="1", parent="1", - status="TRANSFERRED_TO_BENEFICIARY", - hope_status=Payment.STATUS_DISTRIBUTION_SUCCESS, + status="ERROR", + auth_code="1", + fsp_code="1", + message="Error", + ), + PaymentRecordData( + id=2, + remote_id=str(self.payments[1].id), + created="2023-10-10", + modified="2023-10-11", + record_code="2", + parent="2", + status="ERROR", + auth_code="2", + fsp_code="2", + payout_amount=1.23, + ), + PaymentRecordData( + id=3, + remote_id=str(self.payments[2].id), + created="2023-10-10", + modified="2023-10-11", + record_code="3", + parent="3", + status="CANCELLED", + auth_code="3", + fsp_code="3", + ), + ] + + pg_service = PaymentGatewayService() + pg_service.api.get_records_for_payment_instruction = get_records_for_payment_instruction_mock # type: ignore + + pg_service.sync_records() + assert get_records_for_payment_instruction_mock.call_count == 1 + self.pp.refresh_from_db() + self.payments[0].refresh_from_db() + self.payments[1].refresh_from_db() + self.payments[2].refresh_from_db() + assert self.payments[0].status == Payment.STATUS_ERROR + assert self.payments[1].status == Payment.STATUS_ERROR + assert self.payments[2].status == Payment.STATUS_MANUALLY_CANCELLED + assert self.payments[0].reason_for_unsuccessful_payment == "Error" + assert self.payments[1].reason_for_unsuccessful_payment == "Delivered amount: 1.23" + assert self.payments[2].reason_for_unsuccessful_payment == "Unknown error" + assert self.pp.is_reconciled + + @mock.patch("hct_mis_api.apps.payment.models.PaymentPlan.get_exchange_rate", return_value=2.0) + @mock.patch( + "hct_mis_api.apps.payment.services.payment_gateway.PaymentGatewayAPI.get_records_for_payment_instruction" + ) + @mock.patch("hct_mis_api.apps.payment.services.payment_gateway.get_quantity_in_usd", return_value=100.00) + def test_sync_records_error_messages( + self, get_quantity_in_usd_mock: Any, get_records_for_payment_instruction_mock: Any, get_exchange_rate_mock: Any + ) -> None: + self.dm.sent_to_payment_gateway = True + self.dm.save() + + get_records_for_payment_instruction_mock.return_value = [ + PaymentRecordData( + id=1, + remote_id=str(self.payments[0].id), + created="2023-10-10", + modified="2023-10-11", + record_code="1", + parent="1", + status="PENDING", auth_code="1", - payout_amount=float(self.payments[0].entitlement_quantity), fsp_code="1", ), PaymentRecordData( @@ -213,7 +292,6 @@ def test_sync_records( record_code="2", parent="2", status="TRANSFERRED_TO_BENEFICIARY", - hope_status=Payment.STATUS_DISTRIBUTION_SUCCESS, auth_code="2", payout_amount=float(self.payments[1].entitlement_quantity) - 10.00, fsp_code="2", @@ -228,14 +306,53 @@ def test_sync_records( pg_service.sync_records() assert get_records_for_payment_instruction_mock.call_count == 1 + self.pp.refresh_from_db() self.payments[0].refresh_from_db() self.payments[1].refresh_from_db() - assert self.payments[0].status == Payment.STATUS_DISTRIBUTION_SUCCESS + assert self.payments[0].status == Payment.STATUS_SENT_TO_PG assert self.payments[0].fsp_auth_code == "1" - assert self.payments[0].delivered_quantity == self.payments[0].entitlement_quantity + assert self.payments[0].delivered_quantity is None assert self.payments[1].status == Payment.STATUS_DISTRIBUTION_PARTIAL assert self.payments[1].fsp_auth_code == "2" assert self.payments[1].delivered_quantity == self.payments[1].entitlement_quantity - Decimal(10.00) + assert self.pp.is_reconciled is False + + get_records_for_payment_instruction_mock.return_value = [ + PaymentRecordData( + id=1, + remote_id=str(self.payments[0].id), + created="2023-10-10", + modified="2023-10-11", + record_code="1", + parent="1", + status="TRANSFERRED_TO_BENEFICIARY", + auth_code="1", + payout_amount=float(self.payments[0].entitlement_quantity), + fsp_code="1", + ), + PaymentRecordData( + id=2, + remote_id=str(self.payments[1].id), + created="2023-10-10", + modified="2023-10-11", + record_code="2", + parent="2", + status="TRANSFERRED_TO_BENEFICIARY", + auth_code="2", + payout_amount=float(self.payments[1].entitlement_quantity) - 10.00, + fsp_code="2", + ), + ] + + get_records_for_payment_instruction_mock.reset_mock() + pg_service.sync_records() + assert get_records_for_payment_instruction_mock.call_count == 1 + self.payments[0].refresh_from_db() + self.payments[1].refresh_from_db() + assert self.payments[0].status == Payment.STATUS_DISTRIBUTION_SUCCESS + assert self.payments[0].delivered_quantity == self.payments[0].entitlement_quantity + assert self.payments[1].status == Payment.STATUS_DISTRIBUTION_PARTIAL + assert self.payments[1].delivered_quantity == self.payments[1].entitlement_quantity - Decimal(10.00) # pp is reconciled at this point get_records_for_payment_instruction_mock.reset_mock() @@ -251,7 +368,6 @@ def test_get_hope_status(self) -> None: record_code="1", parent="1", status="TRANSFERRED_TO_BENEFICIARY", - hope_status=Payment.STATUS_DISTRIBUTION_SUCCESS, auth_code="1", payout_amount=float(self.payments[0].entitlement_quantity), fsp_code="1", @@ -259,14 +375,12 @@ def test_get_hope_status(self) -> None: self.assertEqual(p.get_hope_status(self.payments[0].entitlement_quantity), Payment.STATUS_DISTRIBUTION_SUCCESS) self.assertEqual(p.get_hope_status(Decimal(1000000.00)), Payment.STATUS_DISTRIBUTION_PARTIAL) - with self.assertRaisesMessage(PaymentGatewayAPI.PaymentGatewayAPIException, "Invalid delivered_quantity"): - p.payout_amount = None # type: ignore - p.get_hope_status(Decimal(1000000.00)) + p.payout_amount = None + self.assertEqual(p.get_hope_status(Decimal(1000000.00)), Payment.STATUS_ERROR) - with self.assertRaisesMessage(PaymentGatewayAPI.PaymentGatewayAPIException, "Invalid Payment status"): - p.payout_amount = float(self.payments[0].entitlement_quantity) - p.status = "NOT EXISTING STATUS" - p.get_hope_status(Decimal(1000000.00)) + p.payout_amount = float(self.payments[0].entitlement_quantity) + p.status = "NOT EXISTING STATUS" + self.assertEqual(p.get_hope_status(Decimal(1000000.00)), Payment.STATUS_ERROR) @mock.patch( "hct_mis_api.apps.payment.services.payment_gateway.PaymentGatewayAPI.add_records_to_payment_instruction" diff --git a/backend/hct_mis_api/apps/payment/utils.py b/backend/hct_mis_api/apps/payment/utils.py index 8d9b28404a..bf2bda0790 100644 --- a/backend/hct_mis_api/apps/payment/utils.py +++ b/backend/hct_mis_api/apps/payment/utils.py @@ -167,7 +167,7 @@ def get_payment_plan_object(cash_or_payment_plan_id: str) -> Union["PaymentPlan" def get_payment_delivered_quantity_status_and_value( - delivered_quantity: Union[int, float, str], entitlement_quantity: Decimal + delivered_quantity: Optional[Union[int, float, str]], entitlement_quantity: Decimal ) -> typing.Tuple[str, Optional[Decimal]]: """ * Fully Delivered (entitled quantity = delivered quantity) [int, float, str] diff --git a/frontend/src/containers/tables/paymentmodule/PaymentsTable/PaymentsTableRow.tsx b/frontend/src/containers/tables/paymentmodule/PaymentsTable/PaymentsTableRow.tsx index 8be7227078..c22b0dc410 100644 --- a/frontend/src/containers/tables/paymentmodule/PaymentsTable/PaymentsTableRow.tsx +++ b/frontend/src/containers/tables/paymentmodule/PaymentsTable/PaymentsTableRow.tsx @@ -85,6 +85,9 @@ export function PaymentsTableRow({ if (status === PaymentStatus.TransactionErroneous) { return UNSUCCESSFUL; } + if (status === PaymentStatus.ManuallyCancelled) { + return CANCELLED; + } if (deliveredQuantity === null) { return <>; }