Skip to content

Commit

Permalink
Merge pull request #4551 from unicef/226250_Payment_Gateway_Read_from…
Browse files Browse the repository at this point in the history
…_HOPE_and_update_instruction_state_from_Processed_to_Finalized_once_PP_state_is_set_to_Finished

226250_Payment_Gateway_Read_from_HOPE_and_update_instruction_state_fr…
  • Loading branch information
johniak authored Jan 16, 2025
2 parents 80a138b + e48ddd4 commit 18c0f2c
Show file tree
Hide file tree
Showing 2 changed files with 115 additions and 78 deletions.
156 changes: 81 additions & 75 deletions src/hct_mis_api/apps/payment/services/payment_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from enum import Enum
from typing import Any, Callable, Dict, List, Optional, Union

from django.db.models import Q
from django.db.models import Q, QuerySet
from django.utils.timezone import now

from _decimal import Decimal
Expand Down Expand Up @@ -50,6 +50,7 @@ class PaymentInstructionStatus(Enum):
CLOSED = "CLOSED"
ABORTED = "ABORTED"
PROCESSED = "PROCESSED"
FINALIZED = "FINALIZED"


class PaymentInstructionFromDeliveryMechanismPerPaymentPlanSerializer(ReadOnlyModelSerializer):
Expand Down Expand Up @@ -456,64 +457,60 @@ def sync_fsps(self) -> None:
)
fsp.delivery_mechanisms.set(delivery_mechanisms)

def sync_records(self) -> None:
def update_payment(
_payment: Payment,
_pg_payment_records: List[PaymentRecordData],
_container: Union[DeliveryMechanismPerPaymentPlan, PaymentPlanSplit],
_payment_plan: PaymentPlan,
_exchange_rate: Decimal,
) -> None:
try:
matching_pg_payment = next(p for p in _pg_payment_records if p.remote_id == str(_payment.id))
except StopIteration:
logger.warning(
f"Payment {_payment.id} for Payment Instruction {_container.id} not found in Payment Gateway"
)
return

_payment.status = matching_pg_payment.get_hope_status(_payment.entitlement_quantity)
_payment.status_date = now()
_payment.fsp_auth_code = matching_pg_payment.auth_code
update_fields = ["status", "status_date", "fsp_auth_code"]

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
if _payment.status in [
Payment.STATUS_DISTRIBUTION_SUCCESS,
Payment.STATUS_DISTRIBUTION_PARTIAL,
Payment.STATUS_NOT_DISTRIBUTED,
]:
if _payment.status == Payment.STATUS_NOT_DISTRIBUTED and delivered_quantity is None:
delivered_quantity = 0

update_fields.extend(["delivered_quantity", "delivered_quantity_usd"])
try:
_payment.delivered_quantity = to_decimal(delivered_quantity)
_payment.delivered_quantity_usd = get_quantity_in_usd(
amount=Decimal(delivered_quantity), # type: ignore
currency=_payment_plan.currency,
exchange_rate=Decimal(_exchange_rate),
currency_exchange_date=_payment_plan.currency_exchange_date,
)
except (ValueError, TypeError):
logger.warning(f"Invalid delivered_amount for Payment {_payment.id}: {delivered_quantity}")
_payment.delivered_quantity = None
_payment.delivered_quantity_usd = None
@staticmethod
def update_payment(
_payment: Payment,
_pg_payment_records: List[PaymentRecordData],
_container: Union[DeliveryMechanismPerPaymentPlan, PaymentPlanSplit],
_payment_plan: PaymentPlan,
_exchange_rate: Decimal,
) -> None:
try:
matching_pg_payment = next(p for p in _pg_payment_records if p.remote_id == str(_payment.id))
except StopIteration:
logger.warning(
f"Payment {_payment.id} for Payment Instruction {_container.id} not found in Payment Gateway"
)
return

_payment.status = matching_pg_payment.get_hope_status(_payment.entitlement_quantity)
_payment.status_date = now()
_payment.fsp_auth_code = matching_pg_payment.auth_code
update_fields = ["status", "status_date", "fsp_auth_code"]

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
if _payment.status in [
Payment.STATUS_DISTRIBUTION_SUCCESS,
Payment.STATUS_DISTRIBUTION_PARTIAL,
Payment.STATUS_NOT_DISTRIBUTED,
]:
if _payment.status == Payment.STATUS_NOT_DISTRIBUTED and delivered_quantity is None:
delivered_quantity = 0

update_fields.extend(["delivered_quantity", "delivered_quantity_usd"])
_payment.delivered_quantity = to_decimal(delivered_quantity)
_payment.delivered_quantity_usd = get_quantity_in_usd(
amount=Decimal(delivered_quantity), # type: ignore
currency=_payment_plan.currency,
exchange_rate=Decimal(_exchange_rate),
currency_exchange_date=_payment_plan.currency_exchange_date,
)

_payment.save(update_fields=update_fields)
_payment.save(update_fields=update_fields)

def sync_records(self) -> None:
payment_plans = PaymentPlan.objects.filter(
Q(delivery_mechanisms__sent_to_payment_gateway=True) | Q(splits__sent_to_payment_gateway=True),
status=PaymentPlan.Status.ACCEPTED,
Expand All @@ -526,30 +523,39 @@ def update_payment(

if not payment_plan.is_reconciled:
if payment_plan.splits.exists():
for split in payment_plan.splits.filter(sent_to_payment_gateway=True):
pending_payments = split.payments.filter(
status__in=self.PENDING_UPDATE_PAYMENT_STATUSES
).order_by("unicef_id")
if pending_payments.exists():
pg_payment_records = self.api.get_records_for_payment_instruction(split.id)
for payment in pending_payments:
update_payment(payment, pg_payment_records, split, payment_plan, exchange_rate)
payment_instructions = payment_plan.splits.filter(sent_to_payment_gateway=True)

def get_pending_payments(split: PaymentPlanSplit) -> QuerySet[Payment]:
return split.payments.filter(status__in=self.PENDING_UPDATE_PAYMENT_STATUSES).order_by(
"unicef_id"
)

else:
for delivery_mechanism in payment_plan.delivery_mechanisms.filter(
payment_instructions = payment_plan.delivery_mechanisms.filter(
financial_service_provider__communication_channel=FinancialServiceProvider.COMMUNICATION_CHANNEL_API,
financial_service_provider__payment_gateway_id__isnull=False,
sent_to_payment_gateway=True,
):
pending_payments = payment_plan.eligible_payments.filter(
financial_service_provider=delivery_mechanism.financial_service_provider,
)

# Explicitly pass `payment_plan` to avoid late binding issues
def get_pending_payments(
dm: DeliveryMechanismPerPaymentPlan, pp: PaymentPlan = payment_plan
) -> QuerySet[Payment]:
return pp.eligible_payments.filter(
financial_service_provider=dm.financial_service_provider,
status__in=self.PENDING_UPDATE_PAYMENT_STATUSES,
).order_by("unicef_id")
if pending_payments.exists():
pg_payment_records = self.api.get_records_for_payment_instruction(delivery_mechanism.id)
for payment in pending_payments:
update_payment(
payment, pg_payment_records, delivery_mechanism, payment_plan, exchange_rate
)

for instruction in payment_instructions:
pending_payments = get_pending_payments(instruction)
if pending_payments.exists():
pg_payment_records = self.api.get_records_for_payment_instruction(instruction.id)
for payment in pending_payments:
self.update_payment(payment, pg_payment_records, instruction, payment_plan, exchange_rate)

if payment_plan.is_reconciled:
for instruction in payment_instructions:
self.change_payment_instruction_status(PaymentInstructionStatus.FINALIZED, instruction)

def sync_delivery_mechanisms(self) -> None:
delivery_mechanisms: List[DeliveryMechanismData] = self.api.get_delivery_mechanisms()
Expand Down
37 changes: 34 additions & 3 deletions tests/unit/apps/payment/test_payment_gateway_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,21 @@ def setUpTestData(cls) -> None:

create_payment_plan_snapshot_data(cls.pp)

@mock.patch(
"hct_mis_api.apps.payment.services.payment_gateway.PaymentGatewayAPI.change_payment_instruction_status",
return_value="FINALIZED",
)
@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_for_split(
self, get_quantity_in_usd_mock: Any, get_records_for_payment_instruction_mock: Any, get_exchange_rate_mock: Any
self,
get_quantity_in_usd_mock: Any,
get_records_for_payment_instruction_mock: Any,
get_exchange_rate_mock: Any,
change_payment_instruction_status_mock: Any,
) -> None:
self.dm.sent_to_payment_gateway = True
self.dm.save()
Expand Down Expand Up @@ -194,11 +202,22 @@ def test_sync_records_for_split(
pg_service.sync_records()
assert get_records_for_payment_instruction_mock.call_count == 0

assert change_payment_instruction_status_mock.call_count == 2

@mock.patch(
"hct_mis_api.apps.payment.services.payment_gateway.PaymentGatewayAPI.change_payment_instruction_status",
return_value="FINALIZED",
)
@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"
)
def test_sync_records(self, get_records_for_payment_instruction_mock: Any, get_exchange_rate_mock: Any) -> None:
def test_sync_records(
self,
get_records_for_payment_instruction_mock: Any,
get_exchange_rate_mock: Any,
change_payment_instruction_status_mock: Any,
) -> None:
for _ in range(2):
collector = IndividualFactory(household=None)
hoh = IndividualFactory(household=None)
Expand Down Expand Up @@ -292,13 +311,23 @@ def test_sync_records(self, get_records_for_payment_instruction_mock: Any, get_e
assert self.payments[2].reason_for_unsuccessful_payment == "Unknown error"
assert self.pp.is_reconciled

change_payment_instruction_status_mock.assert_called_once_with(PaymentInstructionStatus.FINALIZED, self.dm.id)

@mock.patch(
"hct_mis_api.apps.payment.services.payment_gateway.PaymentGatewayAPI.change_payment_instruction_status",
return_value="FINALIZED",
)
@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
self,
get_quantity_in_usd_mock: Any,
get_records_for_payment_instruction_mock: Any,
get_exchange_rate_mock: Any,
change_payment_instruction_status_mock: Any,
) -> None:
self.dm.sent_to_payment_gateway = True
self.dm.save()
Expand Down Expand Up @@ -347,6 +376,7 @@ def test_sync_records_error_messages(
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
assert change_payment_instruction_status_mock.call_count == 0

get_records_for_payment_instruction_mock.return_value = [
PaymentRecordData(
Expand Down Expand Up @@ -389,6 +419,7 @@ def test_sync_records_error_messages(
get_records_for_payment_instruction_mock.reset_mock()
pg_service.sync_records()
assert get_records_for_payment_instruction_mock.call_count == 0
assert change_payment_instruction_status_mock.call_count == 1

def test_get_hope_status(self) -> None:
p = PaymentRecordData(
Expand Down

0 comments on commit 18c0f2c

Please sign in to comment.