Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: part of program refund with single transaction #349

Open
wants to merge 9 commits into
base: program-migration
Choose a base branch
from
93 changes: 88 additions & 5 deletions commerce_coordinator/apps/commercetools/catalog_info/edx_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
EdXFieldNames,
TwoUKeys
)
from commerce_coordinator.apps.commercetools.catalog_info.utils import typed_money_to_string


def get_edx_product_course_run_key(prodvar_or_li: Union[CTProductVariant, CTLineItem]) -> str:
Expand Down Expand Up @@ -80,13 +79,97 @@ def get_edx_is_sanctioned(order: CTOrder) -> bool:
return get_edx_order_workflow_state_key(order) == TwoUKeys.SDN_SANCTIONED_ORDER_STATE


def get_edx_refund_info(payment: CTPayment) -> decimal:
refund_amount = decimal.Decimal(0.00)
def cents_to_dollars(in_amount):
return in_amount.cent_amount / pow(
10, in_amount.fraction_digits
if hasattr(in_amount, 'fraction_digits')
else 2
)


def get_line_item_bundle_id(line_item):
"""
Retrieve the bundle ID from a line item's custom fields.
Args:
line_item (object): The line item object which contains custom fields.
Returns:
str or None: The bundle ID if it exists, otherwise None.
"""
return (
line_item.custom.fields.get(TwoUKeys.LINE_ITEM_BUNDLE_ID)
if line_item.custom
else None
)


def check_is_bundle(line_items):
"""
Checks if any of the line items in the provided list is part of a bundle.
Args:
line_items (list): A list of line items to check.
Returns:
bool: True if at least one line item is part of a bundle, False otherwise.
"""
return any(bool(get_line_item_bundle_id(line_item)) for line_item in line_items)


def get_line_item_price_to_refund(order: CTOrder, return_line_item_ids: List[str]):
"""
Calculate the discounted price of a line item in an order.

Args:
order (CTOrder): The order object containing line items.
return_line_item_ids (List[str]): A list of line item IDs to check for discounted prices.

Returns:
decimal.Decimal: The discounted price of the line item in dollars. Returns 0.00 if no discounted price is found.
"""
if check_is_bundle(order.line_items):
bundle_amount = 0
for line_item in get_edx_items(order):
if line_item.id in return_line_item_ids:
bundle_amount += cents_to_dollars(line_item.total_price)

return bundle_amount

return cents_to_dollars(order.total_price)


def get_edx_refund_info(payment: CTPayment, order: CTOrder, return_line_item_ids: List[str]) -> (decimal.Decimal, str):
"""
Calculate the refund amount for specified line items in an order and retrieve the interaction ID from the payment.

Args:
payment (CTPayment): The payment object containing transaction details.
order (CTOrder): The order object containing line items and pricing details.
return_line_item_ids (List[str]): A list of line item IDs for which the refund is to be calculated.

Returns:
tuple: A tuple containing:
- decimal.Decimal: The total refund amount for the specified line items.
- str: The interaction ID associated with the charge transaction in the payment.
"""
interaction_id = None

for transaction in payment.transactions:
if transaction.type == TransactionType.CHARGE: # pragma no cover
refund_amount += decimal.Decimal(typed_money_to_string(transaction.amount, money_as_decimal_string=True))
interaction_id = transaction.interaction_id
return refund_amount, interaction_id

refund_amount = get_line_item_price_to_refund(order, return_line_item_ids)

return refund_amount, interaction_id


def get_line_item_lms_entitlement_id(line_item):
"""
Retrieve the lms entitlement ID from a line item's custom fields.
Args:
line_item (object): The line item object which contains custom fields.
Returns:
str or None: The lms entitlement ID if it exists, otherwise None.
"""
return (
line_item.custom.fields.get(TwoUKeys.LINE_ITEM_LMS_ENTITLEMENT_ID)
if line_item.custom
else None
)
42 changes: 27 additions & 15 deletions commerce_coordinator/apps/commercetools/clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,8 @@ def update_return_payment_state_after_successful_refund(
self,
order_id: str,
order_version: int,
return_line_item_return_id: str,
return_line_item_return_ids: List[str],
return_line_entitlement_ids: dict,
payment_intent_id: str,
amount_in_cents: decimal,
) -> Union[CTOrder, None]:
Expand All @@ -474,36 +475,47 @@ def update_return_payment_state_after_successful_refund(
try:
logger.info(
f"[CommercetoolsAPIClient] - Updating payment state for return "
f"with id {return_line_item_return_id} to '{ReturnPaymentState.REFUNDED}'."
)
return_payment_state_action = OrderSetReturnPaymentStateAction(
return_item_id=return_line_item_return_id,
payment_state=ReturnPaymentState.REFUNDED,
f"with ids {return_line_item_return_ids} to '{ReturnPaymentState.REFUNDED}'."
)
if not payment_intent_id:
payment_intent_id = ""
logger.info(f"Creating return for order - payment_intent_id: {payment_intent_id}")
payment = self.get_payment_by_key(payment_intent_id)
logger.info(f"Payment found: {payment}")
transaction_id = find_refund_transaction(payment, amount_in_cents)
update_transaction_id_action = OrderSetReturnItemCustomTypeAction(
return_item_id=return_line_item_return_id,
type=CTTypeResourceIdentifier(
key="returnItemCustomType",
),
fields=CTFieldContainer({"transactionId": transaction_id}),
)
return_payment_state_actions = []
update_transaction_id_actions = []
for return_line_item_return_id in return_line_item_return_ids:
return_payment_state_actions.append(OrderSetReturnPaymentStateAction(
return_item_id=return_line_item_return_id,
payment_state=ReturnPaymentState.REFUNDED,
))
custom_fields = {
"transactionId": transaction_id,
}
entitlement_id = return_line_entitlement_ids.get(return_line_item_return_id)
if entitlement_id:
custom_fields[TwoUKeys.LINE_ITEM_LMS_ENTITLEMENT_ID] = entitlement_id
update_transaction_id_actions.append(OrderSetReturnItemCustomTypeAction(
return_item_id=return_line_item_return_id,
type=CTTypeResourceIdentifier(
key="returnItemCustomType",
),
fields=CTFieldContainer(custom_fields),
))
Comment on lines +486 to +505
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: some logical separation

Suggested change
return_payment_state_actions = []
update_transaction_id_actions = []
for return_line_item_return_id in return_line_item_return_ids:
return_payment_state_actions.append(OrderSetReturnPaymentStateAction(
return_item_id=return_line_item_return_id,
payment_state=ReturnPaymentState.REFUNDED,
))
custom_fields = {
"transactionId": transaction_id,
}
entitlement_id = return_line_entitlement_ids.get(return_line_item_return_id)
if entitlement_id:
custom_fields[TwoUKeys.LINE_ITEM_LMS_ENTITLEMENT_ID] = entitlement_id
update_transaction_id_actions.append(OrderSetReturnItemCustomTypeAction(
return_item_id=return_line_item_return_id,
type=CTTypeResourceIdentifier(
key="returnItemCustomType",
),
fields=CTFieldContainer(custom_fields),
))
return_payment_state_actions = []
update_transaction_id_actions = []
for item_id in return_line_item_return_ids:
return_payment_state_actions.append(OrderSetReturnPaymentStateAction(
return_item_id=item_id,
payment_state=ReturnPaymentState.REFUNDED,
))
custom_fields = {"transactionId": transaction_id}
if entitlement_id := return_line_entitlement_ids.get(item_id):
custom_fields[TwoUKeys.LINE_ITEM_LMS_ENTITLEMENT_ID] = entitlement_id
update_transaction_id_actions.append(OrderSetReturnItemCustomTypeAction(
return_item_id=item_id,
type=CTTypeResourceIdentifier(key="returnItemCustomType"),
fields=CTFieldContainer(custom_fields),
))


return_transaction_return_item_action = PaymentSetTransactionCustomTypeAction(
transaction_id=transaction_id,
type=CTTypeResourceIdentifier(key="transactionCustomType"),
fields=CTFieldContainer({"returnItemId": return_line_item_return_id}),
# TODO: ask shafqat what ID should be used here
fields=CTFieldContainer({"returnItemId": return_line_item_return_ids[0]}),
)
logger.info(f"Update return payment state after successful refund - payment_intent_id: {payment_intent_id}")

updated_order = self.base_client.orders.update_by_id(
id=order_id,
version=order_version,
actions=[return_payment_state_action, update_transaction_id_action],
actions=return_payment_state_actions + update_transaction_id_actions,
)
self.base_client.payments.update_by_id(
id=payment.id,
Expand Down
19 changes: 15 additions & 4 deletions commerce_coordinator/apps/commercetools/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@ class OrderRefundRequested(OpenEdxPublicFilter):
filter_type = "org.edx.coordinator.commercetools.order.refund.requested.v1"

@classmethod
def run_filter(cls, order_id, return_line_item_return_id, message_id):
def run_filter(
cls,
order_id,
return_line_item_ids,
return_line_item_return_ids,
return_line_entitlement_ids,
message_id
):
"""
Call the PipelineStep(s) defined for this filter.
Arguments:
Expand All @@ -19,6 +26,10 @@ def run_filter(cls, order_id, return_line_item_return_id, message_id):
Returns:
order_refund: Updated order with return item attached
"""
return super().run_pipeline(order_id=order_id,
return_line_item_return_id=return_line_item_return_id,
message_id=message_id)
return super().run_pipeline(
order_id=order_id,
return_line_item_ids=return_line_item_ids,
return_line_item_return_ids=return_line_item_return_ids,
return_line_entitlement_ids=return_line_entitlement_ids,
message_id=message_id
)
34 changes: 21 additions & 13 deletions commerce_coordinator/apps/commercetools/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,10 @@
from commerce_coordinator.apps.commercetools.clients import CommercetoolsAPIClient
from commerce_coordinator.apps.commercetools.constants import COMMERCETOOLS_ORDER_MANAGEMENT_SYSTEM
from commerce_coordinator.apps.commercetools.data import order_from_commercetools
from commerce_coordinator.apps.commercetools.utils import create_retired_fields, has_refund_transaction
from commerce_coordinator.apps.commercetools.utils import create_retired_fields, has_full_refund_transaction
from commerce_coordinator.apps.core.constants import PipelineCommand
from commerce_coordinator.apps.core.exceptions import InvalidFilterType
from commerce_coordinator.apps.rollout.utils import (
get_order_return_info_return_items,
is_commercetools_line_item_already_refunded
)
from commerce_coordinator.apps.rollout.utils import is_commercetools_line_item_already_refunded
from commerce_coordinator.apps.rollout.waffle import is_redirect_to_commercetools_enabled_for_user

log = getLogger(__name__)
Expand Down Expand Up @@ -132,7 +129,13 @@ def run_filter(self, active_order_management_system, order_number, **kwargs): #
class FetchOrderDetailsByOrderID(PipelineStep):
""" Fetch the order details and if we can, set the PaymentIntent """

def run_filter(self, active_order_management_system, order_id, **kwargs): # pylint: disable=arguments-differ
def run_filter(
self,
active_order_management_system,
order_id,
return_line_item_ids,
**kwargs
): # pylint: disable=arguments-differ
"""
Execute a filter with the signature specified.
Arguments:
Expand Down Expand Up @@ -169,10 +172,11 @@ def run_filter(self, active_order_management_system, order_id, **kwargs): # pyl

if payment:
ct_payment = ct_api_client.get_payment_by_key(payment.interface_id)
refund_amount, ct_transaction_interaction_id = get_edx_refund_info(ct_payment)
refund_amount, ct_transaction_interaction_id = get_edx_refund_info(
ct_payment, ct_order, return_line_item_ids)
ret_val['amount_in_cents'] = refund_amount
ret_val['ct_transaction_interaction_id'] = ct_transaction_interaction_id
ret_val['has_been_refunded'] = has_refund_transaction(ct_payment)
ret_val['has_been_refunded'] = has_full_refund_transaction(ct_payment)
ret_val['payment_data'] = ct_payment
else:
ret_val['amount_in_cents'] = decimal.Decimal(0.00)
Expand Down Expand Up @@ -268,18 +272,22 @@ def run_filter(
Returns:
returned_order: the modified CT order
"""
tag = type(self).__name__

if kwargs.get('charge_already_refunded') or kwargs.get('has_been_refunded'):
log.info(f"[{tag}] refund has already been processed, skipping update payment status")
return PipelineCommand.CONTINUE.value

order = kwargs['order_data']
if 'return_line_item_return_id' not in kwargs:
return_line_item_return_id = get_order_return_info_return_items(order)[0].id
else:
return_line_item_return_id = kwargs['return_line_item_return_id']
return_line_item_return_ids = kwargs['return_line_item_return_ids']
return_line_entitlement_ids = kwargs['return_line_entitlement_ids']

ct_api_client = CommercetoolsAPIClient()
updated_order = ct_api_client.update_return_payment_state_after_successful_refund(
order_id=order.id,
order_version=order.version,
return_line_item_return_id=return_line_item_return_id,
return_line_item_return_ids=return_line_item_return_ids,
return_line_entitlement_ids=return_line_entitlement_ids,
payment_intent_id=kwargs['payment_intent_id'],
amount_in_cents=kwargs['amount_in_cents']
)
Expand Down
13 changes: 3 additions & 10 deletions commerce_coordinator/apps/commercetools/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,14 +147,7 @@ def get_return_info(self):
"""Get the return info from the message detail"""
validated_data = self.validated_data
detail = validated_data.get('detail', {})
return_info = detail.get('returnInfo', {})
items = return_info.get('items', [])
return detail.get('returnInfo', {})

if len(items) > 0:
first_item = items[0] if items else {}
return first_item
else: # pragma no cover
return {}

def get_return_line_item_return_id(self):
return self.get_return_info().get('id', None)
def get_return_line_items(self):
return self.get_return_info().get('items', [])
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def fulfill_order_returned_signal(**kwargs):
""" CoordinatorSignal receiver to invoke Celery Task fulfill_order_returned_signal"""
async_result = fulfill_order_returned_signal_task.delay(
order_id=kwargs['order_id'],
return_line_item_return_id=kwargs['return_line_item_return_id'],
message_id=kwargs['message_id']
return_items=kwargs['return_items'],
message_id=kwargs['message_id'],
)
return async_result.id
Loading
Loading