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

Cancel and refund an order with payment provider #930

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ and this project adheres to

### Added

- Added `refund` state to order flow and added refund action on payment backends
and cancel/refund endpoint for admin users
- Debit installment on pending order transition if due date is on current day
- Display order credit card detail in the back office
- Send an email reminder to the user when an installment
Expand All @@ -34,7 +36,7 @@ and this project adheres to
payments of installments that are in the past
- Deprecated field `has_consent_to_terms` for `Order` model
- Move signature fields before appendices in contract definition template
- Update `handle_notification` signature backend to confirm signature
- Update `handle_notification` signature backend to confirm signature

### Fixed

Expand Down
6 changes: 6 additions & 0 deletions src/backend/joanie/core/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
ORDER_STATE_FAILED_PAYMENT = "failed_payment" # last payment has failed
ORDER_STATE_NO_PAYMENT = "no_payment" # no payment has been made
ORDER_STATE_COMPLETED = "completed" # is completed
ORDER_STATE_REFUND = "refund" # order payments were refund

ORDER_STATE_CHOICES = (
(ORDER_STATE_DRAFT, _("Draft")), # default
Expand All @@ -95,6 +96,7 @@
ORDER_STATE_COMPLETED,
pgettext_lazy("As in: the order is completed.", "Completed"),
),
(ORDER_STATE_REFUND, pgettext_lazy("As in: the order is refund", "Refund")),
)
ORDER_STATE_ALLOW_ENROLLMENT = (
ORDER_STATE_COMPLETED,
Expand Down Expand Up @@ -176,19 +178,23 @@
ACTIVITY_LOG_TYPE_NOTIFICATION = "notification"
ACTIVITY_LOG_TYPE_PAYMENT_SUCCEEDED = "payment_succeeded"
ACTIVITY_LOG_TYPE_PAYMENT_FAILED = "payment_failed"
ACTIVITY_LOG_TYPE_PAYMENT_REFUNDED = "payment_refunded"

ACTIVITY_LOG_TYPE_CHOICES = (
(ACTIVITY_LOG_TYPE_NOTIFICATION, _("Notification")),
(ACTIVITY_LOG_TYPE_PAYMENT_SUCCEEDED, _("Payment succeeded")),
(ACTIVITY_LOG_TYPE_PAYMENT_FAILED, _("Payment failed")),
(ACTIVITY_LOG_TYPE_PAYMENT_REFUNDED, _("Payment refunded")),
)

PAYMENT_STATE_PENDING = "pending"
PAYMENT_STATE_PAID = "paid"
PAYMENT_STATE_REFUSED = "refused"
PAYMENT_STATE_REFUNDED = "refunded"

PAYMENT_STATE_CHOICES = (
(PAYMENT_STATE_PENDING, _("Pending")),
(PAYMENT_STATE_PAID, _("Paid")),
(PAYMENT_STATE_REFUSED, _("Refused")),
(PAYMENT_STATE_REFUNDED, _("Refunded")),
)
19 changes: 17 additions & 2 deletions src/backend/joanie/core/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
ProductTargetCourseRelation,
)
from joanie.core.serializers import AddressSerializer
from joanie.core.utils import contract_definition, file_checksum
from joanie.core.utils import contract_definition, file_checksum, payment_schedule
from joanie.core.utils.payment_schedule import (
convert_amount_str_to_money_object,
convert_date_str_to_date_object,
Expand Down Expand Up @@ -775,6 +775,7 @@ def contract(self, create, extracted, **kwargs):
enums.ORDER_STATE_FAILED_PAYMENT,
enums.ORDER_STATE_COMPLETED,
enums.ORDER_STATE_CANCELED,
enums.ORDER_STATE_REFUND,
]:
if not self.product.contract_definition:
self.product.contract_definition = ContractDefinitionFactory()
Expand Down Expand Up @@ -838,6 +839,7 @@ def credit_card(self):
enums.ORDER_STATE_FAILED_PAYMENT,
enums.ORDER_STATE_COMPLETED,
enums.ORDER_STATE_CANCELED,
enums.ORDER_STATE_REFUND,
]:
from joanie.payment.factories import ( # pylint: disable=import-outside-toplevel, cyclic-import
CreditCardFactory,
Expand Down Expand Up @@ -929,14 +931,18 @@ def billing_address(self, create, extracted, **kwargs):
enums.ORDER_STATE_NO_PAYMENT,
enums.ORDER_STATE_FAILED_PAYMENT,
enums.ORDER_STATE_COMPLETED,
enums.ORDER_STATE_REFUND,
]
and not self.is_free
):
from joanie.payment.factories import ( # pylint: disable=import-outside-toplevel, cyclic-import
TransactionFactory,
)

if target_state == enums.ORDER_STATE_PENDING_PAYMENT:
if target_state in [
enums.ORDER_STATE_PENDING_PAYMENT,
enums.ORDER_STATE_REFUND,
]:
self.payment_schedule[0]["state"] = enums.PAYMENT_STATE_PAID
# Create related transactions when an installment is paid
TransactionFactory(
Expand Down Expand Up @@ -979,6 +985,14 @@ def billing_address(self, create, extracted, **kwargs):
if target_state == enums.ORDER_STATE_CANCELED:
self.flow.cancel()

if (
self.state == enums.ORDER_STATE_PENDING_PAYMENT
and target_state == enums.ORDER_STATE_REFUND
and payment_schedule.has_installment_paid(self)
):
self.flow.cancel()
self.flow.refund()

@factory.post_generation
# pylint: disable=method-hidden
def payment_schedule(self, create, extracted, **kwargs):
Expand Down Expand Up @@ -1257,6 +1271,7 @@ def context(self):
if self.type in [
enums.ACTIVITY_LOG_TYPE_PAYMENT_SUCCEEDED,
enums.ACTIVITY_LOG_TYPE_PAYMENT_FAILED,
enums.ACTIVITY_LOG_TYPE_PAYMENT_REFUNDED,
]:
return {"order_id": str(factory.Faker("uuid4"))}
return {}
19 changes: 19 additions & 0 deletions src/backend/joanie/core/flows/order.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from joanie.core import enums
from joanie.core.utils.payment_schedule import (
has_installment_paid,
has_installments_to_debit,
is_installment_to_debit,
)
Expand Down Expand Up @@ -244,6 +245,23 @@ def failed_payment(self):
Mark order instance as "failed_payment".
"""

def _can_be_state_refund_payment(self):
"""
An order state can be set to refund if the order's state is 'canceled' exclusively.
To refund, there should be at least one installment paid in the payment schedule.
"""
return has_installment_paid(self.instance)

@state.transition(
source=enums.ORDER_STATE_CANCELED,
target=enums.ORDER_STATE_REFUND,
conditions=[_can_be_state_refund_payment],
)
def refund(self):
"""
Mark an order has refund.
"""

def update(self):
"""
Update the order state.
Expand All @@ -258,6 +276,7 @@ def update(self):
self.pending_payment,
self.no_payment,
self.failed_payment,
self.refund,
]:
with suppress(fsm.TransitionNotAllowed):
logger.debug(
Expand Down
13 changes: 13 additions & 0 deletions src/backend/joanie/core/models/activity_logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def validate(self, value, model_instance):
elif activity_log_type in [
enums.ACTIVITY_LOG_TYPE_PAYMENT_SUCCEEDED,
enums.ACTIVITY_LOG_TYPE_PAYMENT_FAILED,
enums.ACTIVITY_LOG_TYPE_PAYMENT_REFUNDED,
]:
self.validate_payment_type(value)
else:
Expand Down Expand Up @@ -109,6 +110,18 @@ def create_payment_failed_activity_log(cls, order):
type=enums.ACTIVITY_LOG_TYPE_PAYMENT_FAILED,
)

@classmethod
def create_payment_refunded_activity_log(cls, order):
"""
Create a payment refunded activity log
"""
return cls.objects.create(
user=order.owner,
level=enums.ACTIVITY_LOG_LEVEL_SUCCESS,
context={"order_id": str(order.id)},
type=enums.ACTIVITY_LOG_TYPE_PAYMENT_REFUNDED,
)

def __str__(self):
return f"{self.user}: {self.level} {self.type}"

Expand Down
16 changes: 15 additions & 1 deletion src/backend/joanie/core/models/products.py
Original file line number Diff line number Diff line change
Expand Up @@ -1123,7 +1123,6 @@ def _set_installment_state(self, installment_id, state):
self.save(update_fields=["payment_schedule"])
self.flow.update()
return

raise ValueError(f"Installment with id {installment_id} not found")

def set_installment_paid(self, installment_id):
Expand All @@ -1140,6 +1139,21 @@ def set_installment_refused(self, installment_id):
ActivityLog.create_payment_failed_activity_log(self)
self._set_installment_state(installment_id, enums.PAYMENT_STATE_REFUSED)

def set_installment_refunded(self, installment_id):
Copy link
Member

Choose a reason for hiding this comment

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

The order state should not be check before trying to change an installment state ? When this method will be called ?

"""
Set the state of an installment to `refunded` in the payment schedule.
"""
ActivityLog.create_payment_refunded_activity_log(self)
for installment in self.payment_schedule:
if (
installment["id"] == installment_id
and installment["state"] == enums.PAYMENT_STATE_PAID
):
installment["state"] = enums.PAYMENT_STATE_REFUNDED
self.save(update_fields=["payment_schedule"])
return
raise ValueError(f"Installment with id {installment_id} cannot be refund")

def get_first_installment_refused(self):
"""
Retrieve the first installment that is refused in payment schedule of an order.
Expand Down
86 changes: 86 additions & 0 deletions src/backend/joanie/core/utils/payment_schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import logging
import uuid
from datetime import date, timedelta
from decimal import Decimal

from django.conf import settings
from django.utils import timezone
Expand All @@ -19,6 +20,7 @@
from joanie.core.exceptions import InvalidConversionError
from joanie.core.utils.emails import prepare_context_for_upcoming_installment, send
from joanie.payment import get_country_calendar
from joanie.payment.models import Invoice, Transaction

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -223,3 +225,87 @@ def send_mail_reminder_for_installment_debit(order, installment):
template_name="installment_reminder",
to_user_email=order.owner.email,
)


def has_installment_paid(order):
"""
Check if at least 1 installment is paid in the payment schedule.
"""
return not order.is_free and any(
installment.get("state") == enums.PAYMENT_STATE_PAID
for installment in order.payment_schedule
)


def get_paid_transactions(order):
"""
Return a transactions queryset that are made from the order on paid installments.
"""
return (
Transaction.objects.filter(
invoice__order=order,
invoice__parent__isnull=False,
)
.distinct()
.order_by("created_on")
.select_related("invoice__order")
)


def get_installment_matching_transaction_amount(order, transaction):
"""
Return the installment that matches the transaction amount of an order's payment schedule.
"""
return next(
(
installment
for installment in order.payment_schedule
if (
installment["state"] == enums.PAYMENT_STATE_PAID
and installment["amount"] == transaction.total
)
),
None,
)


def get_refundable_transactions(order) -> dict:
"""
Returns a dictionary with transaction reference as key and the amount of the installment
that is eligible to refund in an order's payment schedule.
"""
refund_items = {}
for transaction in get_paid_transactions(order):
matching_installment = get_installment_matching_transaction_amount(
order, transaction
)
if matching_installment and transaction.reference not in refund_items:
refund_items[transaction.reference] = matching_installment["amount"]
return refund_items


def handle_refunded_transaction(
invoice,
amount: Decimal,
refund_reference: str,
is_transaction_canceled: bool,
):
"""
Handle the refund of an installment by creating a credit note, a transaction to reflect
the cash movement.
"""
# Create the credit note
credit_note = Invoice.objects.create(
order=invoice.order,
parent=invoice.order.main_invoice,
total=-amount,
recipient_address=invoice.recipient_address,
)
# Create the transaction of the refund
Transaction.objects.create(
total=credit_note.total,
invoice=credit_note,
reference=refund_reference
if not is_transaction_canceled
else f"cancel_{refund_reference}",
)
18 changes: 18 additions & 0 deletions src/backend/joanie/tests/core/models/order/test_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
ORDER_STATE_NO_PAYMENT,
ORDER_STATE_PENDING,
ORDER_STATE_PENDING_PAYMENT,
ORDER_STATE_REFUND,
ORDER_STATE_SIGNING,
ORDER_STATE_TO_SAVE_PAYMENT_METHOD,
ORDER_STATE_TO_SIGN,
Expand Down Expand Up @@ -305,6 +306,23 @@ def test_factory_order_generator_create_transaction_and_children_invoice_when_st
self.assertEqual(Transaction.objects.filter(invoice__order=order).count(), 4)
self.assertEqual(Invoice.objects.filter(parent=order.main_invoice).count(), 4)

def test_factory_order_state_refund(self):
"""
When passing the state `refund` to the `OrderGeneratorFactory`, it should
create an order with a payment schedule where 1 installment has been paid.
"""
order = self.check_order(
ORDER_STATE_REFUND,
has_organization=True,
has_unsigned_contract=False,
is_free=False,
has_payment_method=True,
)

self.assertEqual(order.state, "refund")
self.assertEqual(order.payment_schedule[0]["state"], PAYMENT_STATE_PAID)
self.assertEqual(order.payment_schedule[1]["state"], PAYMENT_STATE_PENDING)


class TestOrderFactory(TestCase):
"""Test suite for the `OrderFactory`."""
Expand Down
Loading