Skip to content

Commit

Permalink
✨(backend) added refund state in order flows
Browse files Browse the repository at this point in the history
We want to be able to refund the installments paid on an order
for a student. This state is only accessible when the order is
canceled. Some adjustments were made for the OrderGeneratorFactory
to use this new state 'refund' in tests. There will be very few
cases where an order can be refund, for example : the session
is canceled, the professor is on sick leave.
  • Loading branch information
jonathanreveille committed Oct 1, 2024
1 parent d92c32e commit a461fb9
Show file tree
Hide file tree
Showing 11 changed files with 399 additions and 16 deletions.
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 @@ -921,14 +923,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 @@ -971,6 +977,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 @@ -1240,6 +1254,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: 16 additions & 0 deletions src/backend/joanie/core/models/products.py
Original file line number Diff line number Diff line change
Expand Up @@ -1140,6 +1140,22 @@ 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):
"""
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
):
self.flow.refund()
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
88 changes: 87 additions & 1 deletion src/backend/joanie/core/utils/payment_schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
from joanie.core import enums
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 import get_country_calendar, get_payment_backend
from joanie.payment.models import Invoice, Transaction

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -219,3 +220,88 @@ 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") in [enums.PAYMENT_STATE_PAID]
for installment in order.payment_schedule
)


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


def get_installment_id_to_refund(order, transaction_amount: str):
"""
Returns the installment that is getting refund from the order payment schedule.
"""
installment_id = None
for installment in order.payment_schedule:
if (
str(installment["amount"]) == transaction_amount
and installment["state"] == enums.PAYMENT_STATE_PAID
):
# Found the first occurence of a PAID installment that corresponds to the amount
# No need to waste more time to iterate
installment_id = installment["id"]
break
return installment_id


def refund_paid_installments(order):
"""
Handle the refund of all installments that are paid in an order's payment schedule.
"""
for transaction in get_transactions_from_installments(order):
payment_backend = get_payment_backend()
refund_reference = payment_backend.cancel_or_refund(
amount=transaction.total,
transaction=transaction,
user_email=order.owner.email,
)
if refund_reference:
installment_id = get_installment_id_to_refund(
order=order, transaction_amount=transaction.total
)
handle_refunded_transaction(
amount=transaction.total,
invoice=transaction.invoice,
refund_reference=refund_reference,
)
order.set_installment_refunded(installment_id)


def handle_refunded_transaction(amount, invoice, refund_reference):
"""
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,
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,
)
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

0 comments on commit a461fb9

Please sign in to comment.