{% include "contract_definition/fragment_appendice_syllabus.html" with syllabus=syllabus %}
{% endif %}
diff --git a/src/backend/joanie/core/templatetags/extra_tags.py b/src/backend/joanie/core/templatetags/extra_tags.py
index b61cfa2bb..be1729873 100644
--- a/src/backend/joanie/core/templatetags/extra_tags.py
+++ b/src/backend/joanie/core/templatetags/extra_tags.py
@@ -3,11 +3,17 @@
import math
from django import template
+from django.conf import settings
from django.contrib.staticfiles import finders
from django.template.defaultfilters import date
from django.utils.dateparse import parse_datetime
+from django.utils.translation import get_language
from django.utils.translation import gettext as _
+from babel.core import Locale
+from babel.numbers import format_currency
+from parler.utils import get_language_settings
+from stockholm import Money
from timedelta_isoformat import timedelta as timedelta_isoformat
from joanie.core.utils import image_to_base64
@@ -96,3 +102,24 @@ def iso8601_to_duration(duration, unit):
return ""
return math.ceil(course_effort_timedelta.total_seconds() / selected_time_unit[unit])
+
+
+@register.filter
+def format_currency_with_symbol(value: Money):
+ """
+ Formats the given value depending on the country's way to format an amount
+ of money and it adds the appropriate currency symbol.
+ It uses the `DEFAULT_CURRENCY` and the active language (`LANGUAGE_CODE`) setting to render the
+ amount accordingly.
+
+ Example :
+ - If you use `fr-fr` for LANGUAGE_CODE : 200,00 €
+ - If you use `en-us` for LANGUAGE_CODE : €200.00
+ """
+ parts = str(value).split()
+ amount = parts[0]
+ return format_currency(
+ amount,
+ settings.DEFAULT_CURRENCY,
+ locale=Locale.parse(get_language_settings(get_language()).get("code"), sep="-"),
+ )
diff --git a/src/backend/joanie/core/utils/contract.py b/src/backend/joanie/core/utils/contract.py
index 716dc06e2..9fa29d227 100644
--- a/src/backend/joanie/core/utils/contract.py
+++ b/src/backend/joanie/core/utils/contract.py
@@ -31,12 +31,15 @@ def _get_base_signature_backend_references(
if not extra_filters:
extra_filters = {}
- base_query = Contract.objects.filter(
- order__state=enums.ORDER_STATE_VALIDATED,
- student_signed_on__isnull=False,
- organization_signed_on__isnull=False,
- **extra_filters,
- ).select_related("order")
+ base_query = (
+ Contract.objects.filter(
+ student_signed_on__isnull=False,
+ organization_signed_on__isnull=False,
+ **extra_filters,
+ )
+ .exclude(order__state=enums.ORDER_STATE_CANCELED)
+ .select_related("order")
+ )
if course_product_relation:
base_query = base_query.filter(
@@ -175,11 +178,11 @@ def get_signature_references(organization_id: str, student_has_not_signed: bool)
return (
Contract.objects.filter(
submitted_for_signature_on__isnull=False,
- order__state=enums.ORDER_STATE_VALIDATED,
order__organization_id=organization_id,
organization_signed_on__isnull=True,
student_signed_on__isnull=student_has_not_signed,
)
+ .exclude(order__state=enums.ORDER_STATE_CANCELED)
.values_list("signature_backend_reference", flat=True)
.distinct()
.iterator()
diff --git a/src/backend/joanie/core/utils/contract_definition.py b/src/backend/joanie/core/utils/contract_definition.py
index ece839226..c4a613735 100644
--- a/src/backend/joanie/core/utils/contract_definition.py
+++ b/src/backend/joanie/core/utils/contract_definition.py
@@ -1,16 +1,17 @@
"""Utility to `generate document context` data"""
+from copy import deepcopy
from datetime import date, timedelta
from django.conf import settings
-from django.contrib.sites.models import Site
from django.utils.duration import duration_iso_string
from django.utils.module_loading import import_string
from django.utils.translation import gettext as _
from babel.numbers import get_currency_symbol
-from joanie.core.utils import image_to_base64
+from joanie.core.models import DocumentImage
+from joanie.core.utils import file_checksum, image_to_base64
# Organization section for generating contract definition
ORGANIZATION_FALLBACK_ADDRESS = {
@@ -24,6 +25,11 @@
"is_main": True,
}
+ORGANIZATION_FALLBACK_LOGO = (
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR"
+ "42mO8cPX6fwAIdgN9pHTGJwAAAABJRU5ErkJggg=="
+)
+
# Student section for generating contract definition
USER_FALLBACK_ADDRESS = {
"address": _(""),
@@ -71,25 +77,11 @@ def generate_document_context(contract_definition=None, user=None, order=None):
from joanie.core.models import Address
from joanie.core.serializers.client import AddressSerializer
- organization_fallback_logo = (
- "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR"
- "42mO8cPX6fwAIdgN9pHTGJwAAAABJRU5ErkJggg=="
- )
-
contract_language = (
contract_definition.language if contract_definition else settings.LANGUAGE_CODE
)
- try:
- site_config = Site.objects.get_current().site_config
- except Site.site_config.RelatedObjectDoesNotExist: # pylint: disable=no-member
- terms_and_conditions = ""
- else:
- terms_and_conditions = site_config.get_terms_and_conditions_in_html(
- contract_language
- )
-
- organization_logo = organization_fallback_logo
+ organization_logo_id = None
organization_name = _("")
organization_representative = _("")
organization_representative_profession = _("")
@@ -129,7 +121,15 @@ def generate_document_context(contract_definition=None, user=None, order=None):
user_phone_number = user.phone_number
if order:
- organization_logo = image_to_base64(order.organization.logo)
+ logo_checksum = file_checksum(order.organization.logo)
+ logo_image, created = DocumentImage.objects.get_or_create(
+ checksum=logo_checksum,
+ defaults={"file": order.organization.logo},
+ )
+ if created:
+ contract_definition.images.set([logo_image])
+ organization_logo_id = str(logo_image.id)
+
organization_name = order.organization.safe_translation_getter(
"title", language_code=contract_language
)
@@ -186,7 +186,6 @@ def generate_document_context(contract_definition=None, user=None, order=None):
"title": contract_title,
"description": contract_description,
"body": contract_body,
- "terms_and_conditions": terms_and_conditions,
"language": contract_language,
},
"course": {
@@ -206,7 +205,7 @@ def generate_document_context(contract_definition=None, user=None, order=None):
},
"organization": {
"address": organization_address,
- "logo": organization_logo,
+ "logo_id": organization_logo_id,
"name": organization_name,
"representative": organization_representative,
"representative_profession": organization_representative_profession,
@@ -221,3 +220,16 @@ def generate_document_context(contract_definition=None, user=None, order=None):
}
return apply_contract_definition_context_processors(context)
+
+
+def embed_images_in_context(context):
+ """Embed images in the context."""
+ edited_context = deepcopy(context)
+ try:
+ logo = DocumentImage.objects.get(id=edited_context["organization"]["logo_id"])
+ edited_context["organization"]["logo"] = image_to_base64(logo.file)
+ except DocumentImage.DoesNotExist:
+ edited_context["organization"]["logo"] = ORGANIZATION_FALLBACK_LOGO
+
+ del edited_context["organization"]["logo_id"]
+ return edited_context
diff --git a/src/backend/joanie/core/utils/course_product_relation.py b/src/backend/joanie/core/utils/course_product_relation.py
index 25f86b963..aa5cb5513 100644
--- a/src/backend/joanie/core/utils/course_product_relation.py
+++ b/src/backend/joanie/core/utils/course_product_relation.py
@@ -1,6 +1,6 @@
"""Utility methods to get all orders and/or certificates from a course product relation."""
-from joanie.core.enums import ORDER_STATE_VALIDATED, PRODUCT_TYPE_CERTIFICATE_ALLOWED
+from joanie.core.enums import ORDER_STATE_COMPLETED, PRODUCT_TYPE_CERTIFICATE_ALLOWED
from joanie.core.models import Certificate, Order
@@ -14,7 +14,7 @@ def get_orders(course_product_relation):
course=course_product_relation.course,
product=course_product_relation.product,
product__type__in=PRODUCT_TYPE_CERTIFICATE_ALLOWED,
- state=ORDER_STATE_VALIDATED,
+ state=ORDER_STATE_COMPLETED,
certificate__isnull=True,
)
.values_list("pk", flat=True)
@@ -30,5 +30,5 @@ def get_generated_certificates(course_product_relation):
order__product=course_product_relation.product,
order__course=course_product_relation.course,
order__certificate__isnull=False,
- order__state=ORDER_STATE_VALIDATED,
+ order__state=ORDER_STATE_COMPLETED,
)
diff --git a/src/backend/joanie/core/utils/course_run.py b/src/backend/joanie/core/utils/course_run.py
index 4ebba7f55..eb86088a2 100644
--- a/src/backend/joanie/core/utils/course_run.py
+++ b/src/backend/joanie/core/utils/course_run.py
@@ -38,6 +38,6 @@ def get_course_run_metrics(resource_link: str):
"nb_validated_certificate_orders": Order.objects.filter(
enrollment__course_run=course_run,
product__type=enums.PRODUCT_TYPE_CERTIFICATE,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
).count(),
}
diff --git a/src/backend/joanie/core/utils/emails.py b/src/backend/joanie/core/utils/emails.py
new file mode 100644
index 000000000..ac17635e6
--- /dev/null
+++ b/src/backend/joanie/core/utils/emails.py
@@ -0,0 +1,93 @@
+"""Utility to prepare email context data variables for installment payments"""
+
+import smtplib
+from logging import getLogger
+
+from django.conf import settings
+from django.core.mail import send_mail
+from django.template.loader import render_to_string
+
+from stockholm import Money
+
+from joanie.core.enums import (
+ PAYMENT_STATE_PAID,
+ PAYMENT_STATE_PENDING,
+ PAYMENT_STATE_REFUSED,
+)
+
+logger = getLogger(__name__)
+
+
+def prepare_context_data(
+ order, installment_amount, product_title, payment_refused: bool
+):
+ """
+ Prepare the context variables for the email when an installment has been paid
+ or refused.
+ """
+ context_data = {
+ "fullname": order.owner.get_full_name() or order.owner.username,
+ "email": order.owner.email,
+ "product_title": product_title,
+ "installment_amount": Money(installment_amount),
+ "product_price": Money(order.product.price),
+ "credit_card_last_numbers": order.credit_card.last_numbers,
+ "order_payment_schedule": order.payment_schedule,
+ "dashboard_order_link": (
+ settings.JOANIE_DASHBOARD_ORDER_LINK.replace(":orderId", str(order.id))
+ ),
+ "site": {
+ "name": settings.JOANIE_CATALOG_NAME,
+ "url": settings.JOANIE_CATALOG_BASE_URL,
+ },
+ "targeted_installment_index": (
+ order.get_installment_index(state=PAYMENT_STATE_REFUSED)
+ if payment_refused
+ else order.get_installment_index(state=PAYMENT_STATE_PAID)
+ ),
+ }
+
+ if not payment_refused:
+ variable_context_part = {
+ "remaining_balance_to_pay": order.get_remaining_balance_to_pay(),
+ "date_next_installment_to_pay": order.get_date_next_installment_to_pay(),
+ }
+ context_data.update(variable_context_part)
+
+ return context_data
+
+
+def prepare_context_for_upcoming_installment(
+ order, installment_amount, product_title, days_until_debit
+):
+ """
+ Prepare the context variables for the email when an upcoming installment payment
+ will be soon debited for a user.
+ """
+ context_data = prepare_context_data(
+ order, installment_amount, product_title, payment_refused=False
+ )
+ context_data["targeted_installment_index"] = order.get_installment_index(
+ state=PAYMENT_STATE_PENDING, find_first=True
+ )
+ context_data["days_until_debit"] = days_until_debit
+
+ return context_data
+
+
+def send(subject, template_vars, template_name, to_user_email):
+ """Send a mail to the user"""
+ try:
+ msg_html = render_to_string(f"mail/html/{template_name}.html", template_vars)
+ msg_plain = render_to_string(f"mail/text/{template_name}.txt", template_vars)
+ send_mail(
+ subject,
+ msg_plain,
+ settings.EMAIL_FROM,
+ [to_user_email],
+ html_message=msg_html,
+ fail_silently=False,
+ )
+ except smtplib.SMTPException as exception:
+ # no exception raised as user can't sometimes change his mail,
+ logger.error("%s purchase order mail %s not send", to_user_email, exception)
diff --git a/src/backend/joanie/core/utils/payment_schedule.py b/src/backend/joanie/core/utils/payment_schedule.py
index 050c09542..9d3e101f1 100644
--- a/src/backend/joanie/core/utils/payment_schedule.py
+++ b/src/backend/joanie/core/utils/payment_schedule.py
@@ -4,14 +4,20 @@
import logging
import uuid
-from datetime import timedelta
+from datetime import date, timedelta
from django.conf import settings
+from django.utils import timezone
+from django.utils.translation import gettext as _
+from django.utils.translation import override
from dateutil.relativedelta import relativedelta
from stockholm import Money, Number
+from stockholm.exceptions import ConversionError
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
logger = logging.getLogger(__name__)
@@ -59,7 +65,7 @@ def _withdrawal_limit_date(signed_contract_date, course_start_date):
def _calculate_due_dates(
- withdrawal_date, course_start_date, course_end_date, percentages_count
+ withdrawal_date, course_start_date, course_end_date, installments_count
):
"""
Calculate the due dates for the order.
@@ -67,18 +73,21 @@ def _calculate_due_dates(
Then the second one can not be before the course start date
The last one can not be after the course end date
"""
- if percentages_count == 1:
- return [withdrawal_date]
+ due_dates = [withdrawal_date]
+
+ second_date = course_start_date
+ if withdrawal_date > second_date:
+ second_date = withdrawal_date + relativedelta(months=1)
+
+ for i in range(installments_count - len(due_dates)):
+ due_date = second_date + relativedelta(months=i)
- due_dates = [withdrawal_date, course_start_date]
- for i in range(1, percentages_count - 1):
- due_date = course_start_date + relativedelta(months=i)
if due_date > course_end_date:
# If due date is after end date, we should stop the loop, and add the end
# date as the last due date
due_dates.append(course_end_date)
break
- due_dates.append(min(due_date, course_end_date))
+ due_dates.append(due_date)
return due_dates
@@ -86,7 +95,7 @@ def _calculate_installments(total, due_dates, percentages):
"""
Calculate the installments for the order.
"""
- total_amount = Money(total, settings.DEFAULT_CURRENCY)
+ total_amount = Money(total)
installments = []
for i, due_date in enumerate(due_dates):
if i < len(due_dates) - 1:
@@ -124,3 +133,89 @@ def generate(total, beginning_contract_date, course_start_date, course_end_date)
installments = _calculate_installments(total, due_dates, percentages)
return installments
+
+
+def is_installment_to_debit(installment):
+ """
+ Check if the installment is pending and has reached due date.
+ """
+ due_date = timezone.localdate()
+
+ return (
+ installment["state"] == enums.PAYMENT_STATE_PENDING
+ and installment["due_date"] <= due_date
+ )
+
+
+def is_next_installment_to_debit(installment, due_date):
+ """
+ Check if the installment is pending and also if its due date will be equal to the parameter
+ `due_date` passed.
+ """
+
+ return (
+ installment["state"] == enums.PAYMENT_STATE_PENDING
+ and installment["due_date"] == due_date
+ )
+
+
+def has_installments_to_debit(order):
+ """
+ Check if the order has any pending installments with reached due date.
+ """
+
+ return any(
+ is_installment_to_debit(installment) for installment in order.payment_schedule
+ )
+
+
+def convert_date_str_to_date_object(date_str: str):
+ """
+ Converts the `date_str` string into a date object.
+ """
+ try:
+ return date.fromisoformat(date_str)
+ except ValueError as exception:
+ raise InvalidConversionError(
+ f"Invalid date format for date_str: {exception}."
+ ) from exception
+
+
+def convert_amount_str_to_money_object(amount_str: str):
+ """
+ Converts the `amount_str` string into a Money object.
+ """
+ try:
+ return Money(amount_str)
+ except ConversionError as exception:
+ raise InvalidConversionError(
+ f"Invalid format for amount: {exception} : '{amount_str}'."
+ ) from exception
+
+
+def send_mail_reminder_for_installment_debit(order, installment):
+ """
+ Prepare the context variables for the mail reminder when the next installment debit
+ from the payment schedule will happen for the owner of the order.
+ """
+ with override(order.owner.language):
+ product_title = order.product.safe_translation_getter(
+ "title", language_code=order.owner.language
+ )
+ currency = settings.DEFAULT_CURRENCY
+ days_until_debit = settings.JOANIE_INSTALLMENT_REMINDER_PERIOD_DAYS
+ installment_amount = Money(installment["amount"])
+ subject = _(
+ f"{settings.JOANIE_CATALOG_NAME} - {product_title} - "
+ f"An installment of {installment_amount} {currency} will be debited in "
+ f"{days_until_debit} days."
+ )
+
+ send(
+ subject=subject,
+ template_vars=prepare_context_for_upcoming_installment(
+ order, installment_amount, product_title, days_until_debit
+ ),
+ template_name="installment_reminder",
+ to_user_email=order.owner.email,
+ )
diff --git a/src/backend/joanie/core/utils/sentry.py b/src/backend/joanie/core/utils/sentry.py
index 2434f583d..38c9a49ab 100644
--- a/src/backend/joanie/core/utils/sentry.py
+++ b/src/backend/joanie/core/utils/sentry.py
@@ -24,6 +24,8 @@ def default(self, o):
return o.domain
if o.__class__.__name__ == "Decimal" or isinstance(o, Exception):
return str(o)
+ if o.__class__.__name__ == "Money":
+ return str(o.amount)
return super().default(o)
diff --git a/src/backend/joanie/debug/urls.py b/src/backend/joanie/debug/urls.py
index 01d8cdb80..7c8de8244 100644
--- a/src/backend/joanie/debug/urls.py
+++ b/src/backend/joanie/debug/urls.py
@@ -10,6 +10,14 @@
DebugContractTemplateView,
DebugDegreeTemplateView,
DebugInvoiceTemplateView,
+ DebugMailAllInstallmentPaidViewHtml,
+ DebugMailAllInstallmentPaidViewTxt,
+ DebugMailInstallmentRefusedPaymentViewHtml,
+ DebugMailInstallmentRefusedPaymentViewTxt,
+ DebugMailInstallmentReminderPaymentViewHtml,
+ DebugMailInstallmentReminderPaymentViewTxt,
+ DebugMailSuccessInstallmentPaidViewHtml,
+ DebugMailSuccessInstallmentPaidViewTxt,
DebugMailSuccessPaymentViewHtml,
DebugMailSuccessPaymentViewTxt,
DebugPaymentTemplateView,
@@ -51,4 +59,44 @@
DebugPaymentTemplateView.as_view(),
name="debug.payment_template",
),
+ path(
+ "__debug__/mail/installment-paid-html",
+ DebugMailSuccessInstallmentPaidViewHtml.as_view(),
+ name="debug.mail.installment_paid_html",
+ ),
+ path(
+ "__debug__/mail/installment-paid-txt",
+ DebugMailSuccessInstallmentPaidViewTxt.as_view(),
+ name="debug.mail.installment_paid_txt",
+ ),
+ path(
+ "__debug__/mail/installments-fully-paid-html",
+ DebugMailAllInstallmentPaidViewHtml.as_view(),
+ name="debug.mail.installments_fully_paid_html",
+ ),
+ path(
+ "__debug__/mail/installments-fully-paid-txt",
+ DebugMailAllInstallmentPaidViewTxt.as_view(),
+ name="debug.mail.installments_fully_paid_txt",
+ ),
+ path(
+ "__debug__/mail/installment-refused-html",
+ DebugMailInstallmentRefusedPaymentViewHtml.as_view(),
+ name="debug.mail.installment_refused_html",
+ ),
+ path(
+ "__debug__/mail/installment-refused-txt",
+ DebugMailInstallmentRefusedPaymentViewTxt.as_view(),
+ name="debug.mail.installment_refused_txt",
+ ),
+ path(
+ "__debug__/mail/installment-reminder-html",
+ DebugMailInstallmentReminderPaymentViewHtml.as_view(),
+ name="debug.mail.installment_reminder_html",
+ ),
+ path(
+ "__debug__/mail/installment-reminder-txt",
+ DebugMailInstallmentReminderPaymentViewTxt.as_view(),
+ name="debug.mail.installment_reminder_txt",
+ ),
]
diff --git a/src/backend/joanie/debug/views.py b/src/backend/joanie/debug/views.py
index 612a0203e..c4aed928c 100644
--- a/src/backend/joanie/debug/views.py
+++ b/src/backend/joanie/debug/views.py
@@ -10,22 +10,35 @@
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse
+from django.utils import translation
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.views.generic.base import TemplateView
from factory import random
+from stockholm import Money
from joanie.core import factories
-from joanie.core.enums import CERTIFICATE, CONTRACT_DEFINITION, DEGREE
-from joanie.core.factories import OrderFactory, ProductFactory, UserFactory
+from joanie.core.enums import (
+ CERTIFICATE,
+ CONTRACT_DEFINITION,
+ DEGREE,
+ ORDER_STATE_PENDING_PAYMENT,
+ PAYMENT_STATE_PAID,
+ PAYMENT_STATE_PENDING,
+ PAYMENT_STATE_REFUSED,
+)
+from joanie.core.factories import (
+ OrderGeneratorFactory,
+ ProductFactory,
+ UserFactory,
+)
from joanie.core.models import Certificate, Contract
from joanie.core.utils import contract_definition, issuers
from joanie.core.utils.sentry import decrypt_data
from joanie.payment import get_payment_backend
from joanie.payment.enums import INVOICE_TYPE_INVOICE
-from joanie.payment.factories import BillingAddressDictFactory
-from joanie.payment.models import CreditCard, Invoice
+from joanie.payment.models import CreditCard, Invoice, Transaction
logger = getLogger(__name__)
LOGO_FALLBACK = (
@@ -71,6 +84,180 @@ class DebugMailSuccessPaymentViewTxt(DebugMailSuccessPayment):
template_name = "mail/text/order_validated.txt"
+class DebugMailInstallmentPayment(TemplateView):
+ """Debug View to check the layout of the success installment payment by email"""
+
+ def get_context_data(self, **kwargs):
+ """
+ Base method to prepare the document context to render in the email for the debug view.
+ Usage reminder :
+ /__debug__/mail/installment_paid_html
+ """
+ product = ProductFactory(price=Decimal("1000.00"))
+ product.set_current_language("en-us")
+ product.title = "Test product"
+ product.set_current_language("fr-fr")
+ product.title = "Test produit"
+ product.save()
+ course = product.courses.first()
+ course.translations.filter(title="Course 1").update(language_code="en-us")
+ course.translations.filter(title="Cours 1").update(language_code="fr-fr")
+ order = OrderGeneratorFactory(
+ product=product,
+ state=ORDER_STATE_PENDING_PAYMENT,
+ owner=UserFactory(first_name="John", last_name="Doe", language="en-us"),
+ )
+ invoice = Invoice.objects.create(
+ order=order,
+ parent=order.main_invoice,
+ total=0,
+ recipient_address=order.main_invoice.recipient_address,
+ )
+ for payment in order.payment_schedule[:2]:
+ payment["state"] = PAYMENT_STATE_PAID
+ Transaction.objects.create(
+ total=Decimal(payment["amount"].amount),
+ invoice=invoice,
+ reference=payment["id"],
+ )
+ current_language = translation.get_language()
+ with translation.override(current_language):
+ product.set_current_language(current_language)
+ return super().get_context_data(
+ order=order,
+ product_title=product.title,
+ order_payment_schedule=order.payment_schedule,
+ installment_amount=Money(order.payment_schedule[2]["amount"]),
+ product_price=Money(order.product.price),
+ remaining_balance_to_pay=order.get_remaining_balance_to_pay(),
+ date_next_installment_to_pay=order.get_date_next_installment_to_pay(),
+ credit_card_last_numbers=order.credit_card.last_numbers,
+ targeted_installment_index=order.get_installment_index(
+ state=PAYMENT_STATE_PAID
+ ),
+ fullname=order.owner.get_full_name() or order.owner.username,
+ email=order.owner.email,
+ dashboard_order_link=settings.JOANIE_DASHBOARD_ORDER_LINK,
+ site={
+ "name": settings.JOANIE_CATALOG_NAME,
+ "url": settings.JOANIE_CATALOG_BASE_URL,
+ },
+ **kwargs,
+ )
+
+
+class DebugMailSuccessInstallmentPaidViewHtml(DebugMailInstallmentPayment):
+ """Debug View to check the layout of the success installment payment email
+ in html format."""
+
+ template_name = "mail/html/installment_paid.html"
+
+
+class DebugMailSuccessInstallmentPaidViewTxt(DebugMailInstallmentPayment):
+ """Debug View to check the layout of the success installment payment email
+ in txt format."""
+
+ template_name = "mail/text/installment_paid.txt"
+
+
+class DebugMailAllInstallmentPaid(DebugMailInstallmentPayment):
+ """Debug View to check the layout of when all installments are paid by email"""
+
+ def get_context_data(self, **kwargs):
+ """
+ Base method to prepare the document context to render in the email for the debug view.
+ """
+ context = super().get_context_data()
+ order = context.get("order")
+ for payment in order.payment_schedule:
+ payment["state"] = PAYMENT_STATE_PAID
+ context["installment_amount"] = Money(order.payment_schedule[-1]["amount"])
+ context["targeted_installment_index"] = order.get_installment_index(
+ state=PAYMENT_STATE_PAID
+ )
+
+ return context
+
+
+class DebugMailAllInstallmentPaidViewHtml(DebugMailAllInstallmentPaid):
+ """Debug View to check the layout of when all installments are paid by email
+ in html format."""
+
+ template_name = "mail/html/installments_fully_paid.html"
+
+
+class DebugMailAllInstallmentPaidViewTxt(DebugMailAllInstallmentPaid):
+ """Debug View to check the layout of when all installments are paid by email
+ in txt format."""
+
+ template_name = "mail/text/installments_fully_paid.txt"
+
+
+class DebugMailInstallmentRefusedPayment(DebugMailInstallmentPayment):
+ """Debug View to check the layout of when an installment debit is refused by email"""
+
+ def get_context_data(self, **kwargs):
+ """
+ Base method to prepare the document context to render in the email for the debug view.
+ """
+
+ context = super().get_context_data()
+ order = context.get("order")
+ order.payment_schedule[2]["state"] = PAYMENT_STATE_REFUSED
+ context["targeted_installment_index"] = order.get_installment_index(
+ state=PAYMENT_STATE_REFUSED
+ )
+ context["installment_amount"] = Money(order.payment_schedule[2]["amount"])
+
+ return context
+
+
+class DebugMailInstallmentRefusedPaymentViewHtml(DebugMailInstallmentRefusedPayment):
+ """Debug View to check the layout of when an installment debit is refused by email
+ in html format."""
+
+ template_name = "mail/html/installment_refused.html"
+
+
+class DebugMailInstallmentRefusedPaymentViewTxt(DebugMailInstallmentRefusedPayment):
+ """Debug View to check the layout of when an installment debit is refused by email
+ in txt format."""
+
+ template_name = "mail/text/installment_refused.txt"
+
+
+class DebugMailInstallmentReminderPayment(DebugMailInstallmentPayment):
+ """Debug View to check the layout of the debit installment reminder email"""
+
+ def get_context_data(self, **kwargs):
+ """
+ Base method to prepare the document context to render in the email for the debug view.
+ """
+ context = super().get_context_data()
+ order = context.get("order")
+ context["installment_amount"] = order.payment_schedule[2]["amount"]
+ context["targeted_installment_index"] = order.get_installment_index(
+ state=PAYMENT_STATE_PENDING, find_first=True
+ )
+ context["days_until_debit"] = settings.JOANIE_INSTALLMENT_REMINDER_PERIOD_DAYS
+
+ return context
+
+
+class DebugMailInstallmentReminderPaymentViewHtml(DebugMailInstallmentReminderPayment):
+ """Debug View to check the layout of debit reminder of installment by email
+ in html format."""
+
+ template_name = "mail/html/installment_reminder.html"
+
+
+class DebugMailInstallmentReminderPaymentViewTxt(DebugMailInstallmentReminderPayment):
+ """Debug View to check the layout of debit reminder of installment by email
+ in html format."""
+
+ template_name = "mail/text/installment_reminder.txt"
+
+
class DebugPdfTemplateView(TemplateView):
"""
Simple class to render the PDF template in bytes format of a document to preview.
@@ -311,27 +498,36 @@ def get_context_data(self, **kwargs):
product.set_current_language("fr-fr")
product.title = "Test produit"
product.save()
- order = OrderFactory(owner=owner, product=product)
- billing_address = BillingAddressDictFactory()
+ order = OrderGeneratorFactory(
+ owner=owner, product=product, state=ORDER_STATE_PENDING_PAYMENT
+ )
+ billing_address = order.main_invoice.recipient_address
credit_card = CreditCard.objects.filter(owner=owner, is_main=True).first()
one_click = "one-click" in self.request.GET
tokenize_card = "tokenize-card" in self.request.GET
zero_click = "zero-click" in self.request.GET
+ tokenize_card_user = "tokenize-card-user" in self.request.GET
payment_infos = None
response = None
if zero_click and credit_card:
response = backend.create_zero_click_payment(
- order, credit_card.token, order.total
+ order, order.payment_schedule[0], credit_card.token
)
elif tokenize_card:
- payment_infos = backend.tokenize_card(order, billing_address)
+ payment_infos = backend.tokenize_card(
+ order=order, billing_address=billing_address
+ )
+ elif tokenize_card_user:
+ payment_infos = backend.tokenize_card(user=owner)
elif credit_card is not None and one_click:
payment_infos = backend.create_one_click_payment(
- order, billing_address, credit_card.token
+ order, order.payment_schedule[0], credit_card.token, billing_address
)
else:
- payment_infos = backend.create_payment(order, billing_address)
+ payment_infos = backend.create_payment(
+ order, order.payment_schedule[0], billing_address
+ )
form_token = payment_infos.get("form_token") if not zero_click else None
diff --git a/src/backend/joanie/demo/management/commands/create_dev_demo.py b/src/backend/joanie/demo/management/commands/create_dev_demo.py
index 9e2d36c52..9f7f67e23 100644
--- a/src/backend/joanie/demo/management/commands/create_dev_demo.py
+++ b/src/backend/joanie/demo/management/commands/create_dev_demo.py
@@ -177,7 +177,7 @@ def create_product_purchased(
course_user,
organization,
product_type=enums.PRODUCT_TYPE_CERTIFICATE,
- order_status=enums.ORDER_STATE_VALIDATED,
+ order_status=enums.ORDER_STATE_COMPLETED,
contract_definition=None,
product=None,
): # pylint: disable=too-many-arguments
@@ -223,7 +223,7 @@ def create_product_purchased_with_certificate(
course_user,
organization,
options["product_type"],
- enums.ORDER_STATE_VALIDATED,
+ enums.ORDER_STATE_COMPLETED,
options["contract_definition"]
if "contract_definition" in options
else None,
@@ -508,7 +508,7 @@ def handle(self, *args, **options): # pylint: disable=too-many-locals,too-many-
organization_owner,
organization,
enums.PRODUCT_TYPE_CREDENTIAL,
- enums.ORDER_STATE_VALIDATED,
+ enums.ORDER_STATE_COMPLETED,
factories.ContractDefinitionFactory(),
)
factories.ContractFactory(
@@ -528,7 +528,7 @@ def handle(self, *args, **options): # pylint: disable=too-many-locals,too-many-
organization_owner,
organization,
enums.PRODUCT_TYPE_CREDENTIAL,
- enums.ORDER_STATE_VALIDATED,
+ enums.ORDER_STATE_COMPLETED,
factories.ContractDefinitionFactory(),
)
@@ -545,7 +545,7 @@ def handle(self, *args, **options): # pylint: disable=too-many-locals,too-many-
organization_owner,
organization,
enums.PRODUCT_TYPE_CREDENTIAL,
- enums.ORDER_STATE_VALIDATED,
+ enums.ORDER_STATE_COMPLETED,
factories.ContractDefinitionFactory(),
product=learner_signed_order.product,
)
@@ -569,7 +569,7 @@ def handle(self, *args, **options): # pylint: disable=too-many-locals,too-many-
organization_owner,
organization,
enums.PRODUCT_TYPE_CREDENTIAL,
- enums.ORDER_STATE_VALIDATED,
+ enums.ORDER_STATE_COMPLETED,
factories.ContractDefinitionFactory(),
)
@@ -599,13 +599,7 @@ def handle(self, *args, **options): # pylint: disable=too-many-locals,too-many-
)
# Order for all existing status on PRODUCT_CREDENTIAL
- for order_status in [
- enums.ORDER_STATE_DRAFT,
- enums.ORDER_STATE_SUBMITTED,
- enums.ORDER_STATE_PENDING,
- enums.ORDER_STATE_CANCELED,
- enums.ORDER_STATE_VALIDATED,
- ]:
+ for order_status, _ in enums.ORDER_STATE_CHOICES:
self.create_product_purchased(
student_user,
organization_owner,
diff --git a/src/backend/joanie/edx_imports/edx_factories.py b/src/backend/joanie/edx_imports/edx_factories.py
index 4d980f447..a4c2aedf1 100644
--- a/src/backend/joanie/edx_imports/edx_factories.py
+++ b/src/backend/joanie/edx_imports/edx_factories.py
@@ -3,6 +3,7 @@
import random
import factory
+from factory import lazy_attribute
from faker import Faker
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, registry
@@ -205,7 +206,6 @@ class Meta:
id = factory.Sequence(lambda n: n)
username = factory.Sequence(lambda n: f"{faker.user_name()}{n}")
password = factory.Faker("password")
- email = factory.Faker("email")
first_name = ""
last_name = ""
is_active = True
@@ -220,6 +220,11 @@ class Meta:
EdxUserPreferenceFactory, "user", size=3, user_id=factory.SelfAttribute("..id")
)
+ @lazy_attribute
+ def email(self):
+ """Generate a fake email address for the user."""
+ return f"{self.username}@example.com"
+
class EdxEnrollmentFactory(factory.alchemy.SQLAlchemyModelFactory):
"""
diff --git a/src/backend/joanie/lms_handler/backends/openedx.py b/src/backend/joanie/lms_handler/backends/openedx.py
index 71a738129..b2b4e5d5e 100644
--- a/src/backend/joanie/lms_handler/backends/openedx.py
+++ b/src/backend/joanie/lms_handler/backends/openedx.py
@@ -131,7 +131,7 @@ def set_enrollment(self, enrollment):
if Order.objects.filter(
Q(target_courses=enrollment.course_run.course)
| Q(enrollment=enrollment),
- state=enums.ORDER_STATE_VALIDATED,
+ state__in=enums.ORDER_STATE_ALLOW_ENROLLMENT,
owner=enrollment.user,
).exists()
else OPENEDX_MODE_HONOR
diff --git a/src/backend/joanie/payment/api.py b/src/backend/joanie/payment/api.py
index 8575e12b9..1fc8428e9 100644
--- a/src/backend/joanie/payment/api.py
+++ b/src/backend/joanie/payment/api.py
@@ -68,7 +68,9 @@ def get_queryset(self):
else self.request.user.username
)
- return models.CreditCard.objects.get_cards_for_owner(username=username)
+ return models.CreditCard.objects.get_cards_for_owner(
+ username=username
+ ).order_by("-is_main", "-created_on")
@action(
methods=["POST"],
diff --git a/src/backend/joanie/payment/backends/base.py b/src/backend/joanie/payment/backends/base.py
index 1c10c1d55..56b20c38b 100644
--- a/src/backend/joanie/payment/backends/base.py
+++ b/src/backend/joanie/payment/backends/base.py
@@ -1,17 +1,17 @@
"""Base Payment Backend"""
-import smtplib
from logging import getLogger
from django.conf import settings
from django.contrib.sites.models import Site
-from django.core.mail import send_mail
-from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.translation import gettext as _
from django.utils.translation import override
-from joanie.core.models import ActivityLog, Address
+from stockholm import Money
+
+from joanie.core.enums import ORDER_STATE_COMPLETED
+from joanie.core.utils import emails
from joanie.payment.enums import INVOICE_STATE_REFUNDED
from joanie.payment.models import Invoice, Transaction
@@ -36,29 +36,13 @@ def _do_on_payment_success(cls, order, payment):
Generic actions triggered when a succeeded payment has been received.
It creates an invoice and registers the debit transaction,
then mark invoice as paid if transaction amount is equal to the invoice amount
- then mark the order as validated
- """
- # - Create an invoice
- address, _ = Address.objects.get_or_create(
- **payment["billing_address"],
- owner=order.owner,
- defaults={
- "is_reusable": False,
- "title": f"Billing address of order {order.id}",
- },
- )
-
- main_invoice, _ = Invoice.objects.get_or_create(
- order=order,
- total=order.total,
- recipient_address=address,
- )
-
+ then mark the order as completed
+ """
invoice = Invoice.objects.create(
order=order,
- parent=main_invoice,
+ parent=order.main_invoice,
total=0,
- recipient_address=address,
+ recipient_address=order.main_invoice.recipient_address,
)
# - Store the payment transaction
@@ -68,23 +52,29 @@ def _do_on_payment_success(cls, order, payment):
reference=payment["id"],
)
- if payment.get("installment_id"):
- order.set_installment_paid(payment["installment_id"])
- else:
- # - Mark order as validated
- order.flow.validate()
- ActivityLog.create_payment_succeeded_activity_log(order)
+ order.set_installment_paid(payment["installment_id"])
- # send mail
- cls._send_mail_payment_success(order)
+ upcoming_installment = order.state == ORDER_STATE_COMPLETED
+ # Because with Lyra Payment Provider, we get the value in cents
+ cls._send_mail_payment_installment_success(
+ order=order,
+ amount=payment["amount"]
+ if "." in str(payment["amount"])
+ else payment["amount"] / 100,
+ upcoming_installment=not upcoming_installment,
+ )
@classmethod
- def _send_mail_payment_success(cls, order):
- """Send mail with the current language of the user"""
- try:
- with override(order.owner.language):
- template_vars = {
- "title": _("Purchase order confirmed!"),
+ def _send_mail_subscription_success(cls, order):
+ """
+ Send mail with the current language of the user when an order subscription is
+ confirmed
+ """
+ with override(order.owner.language):
+ emails.send(
+ subject=_("Subscription confirmed!"),
+ template_vars={
+ "title": _("Subscription confirmed!"),
"email": order.owner.email,
"fullname": order.owner.get_full_name() or order.owner.username,
"product": order.product,
@@ -92,39 +82,96 @@ def _send_mail_payment_success(cls, order):
"name": settings.JOANIE_CATALOG_NAME,
"url": settings.JOANIE_CATALOG_BASE_URL,
},
- }
- msg_html = render_to_string(
- "mail/html/order_validated.html", template_vars
- )
- msg_plain = render_to_string(
- "mail/text/order_validated.txt", template_vars
+ },
+ template_name="order_validated",
+ to_user_email=order.owner.email,
+ )
+
+ @classmethod
+ def _send_mail_payment_installment_success(
+ cls, order, amount, upcoming_installment
+ ):
+ """
+ Send mail using the current language of the user when an installment is successfully paid
+ and also when all the installments are paid.
+ """
+ with override(order.owner.language):
+ product_title = order.product.safe_translation_getter(
+ "title", language_code=order.owner.language
+ )
+ base_subject = _(f"{settings.JOANIE_CATALOG_NAME} - {product_title} - ")
+ installment_amount = Money(amount)
+ currency = settings.DEFAULT_CURRENCY
+ if upcoming_installment:
+ variable_subject_part = _(
+ f"An installment has been successfully paid of {installment_amount} {currency}"
)
- send_mail(
- _("Purchase order confirmed!"),
- msg_plain,
- settings.EMAIL_FROM,
- [order.owner.email],
- html_message=msg_html,
- fail_silently=False,
+ else:
+ variable_subject_part = _(
+ f"Order completed ! The last installment of {installment_amount} {currency} "
+ "has been debited"
)
- except smtplib.SMTPException as exception:
- # no exception raised as user can't sometimes change his mail,
- logger.error(
- "%s purchase order mail %s not send", order.owner.email, exception
+ emails.send(
+ subject=f"{base_subject}{variable_subject_part}",
+ template_vars=emails.prepare_context_data(
+ order,
+ amount,
+ product_title,
+ payment_refused=False,
+ ),
+ template_name="installment_paid"
+ if upcoming_installment
+ else "installments_fully_paid",
+ to_user_email=order.owner.email,
)
- @staticmethod
- def _do_on_payment_failure(order, installment_id=None):
+ @classmethod
+ def _send_mail_refused_debit(cls, order, installment_id):
+ """
+ Prepare mail context when debit has been refused for an installment in the
+ the current language of the user.
+ """
+ try:
+ installment_amount = Money(
+ next(
+ installment["amount"]
+ for installment in order.payment_schedule
+ if installment["id"] == installment_id
+ ),
+ currency=settings.DEFAULT_CURRENCY,
+ )
+ except StopIteration as exception:
+ raise ValueError(
+ f"Payment Base Backend: {installment_id} not found!"
+ ) from exception
+
+ with override(order.owner.language):
+ product_title = order.product.safe_translation_getter(
+ "title", language_code=order.owner.language
+ )
+ emails.send(
+ subject=_(
+ f"{settings.JOANIE_CATALOG_NAME} - {product_title} - An installment debit "
+ f"has failed {installment_amount} {settings.DEFAULT_CURRENCY}"
+ ),
+ template_vars=emails.prepare_context_data(
+ order,
+ installment_amount,
+ product_title,
+ payment_refused=True,
+ ),
+ template_name="installment_refused",
+ to_user_email=order.owner.email,
+ )
+
+ @classmethod
+ def _do_on_payment_failure(cls, order, installment_id):
"""
Generic actions triggered when a failed payment has been received.
Mark the invoice as pending.
"""
- if installment_id:
- order.set_installment_refused(installment_id)
- else:
- # - Unvalidate order
- order.flow.pending()
- ActivityLog.create_payment_failed_activity_log(order)
+ order.set_installment_refused(installment_id)
+ cls._send_mail_refused_debit(order, installment_id)
@staticmethod
def _do_on_refund(amount, invoice, refund_reference):
@@ -161,7 +208,7 @@ def get_notification_url():
path = reverse("payment_webhook")
return f"https://{site.domain}{path}"
- def create_payment(self, order, billing_address, installment=None):
+ def create_payment(self, order, installment, billing_address):
"""
Method used to create a payment from the payment provider.
"""
@@ -170,7 +217,7 @@ def create_payment(self, order, billing_address, installment=None):
)
def create_one_click_payment(
- self, order, billing_address, credit_card_token, installment=None
+ self, order, installment, credit_card_token, billing_address
):
"""
Method used to create a one click payment from the payment provider.
@@ -179,7 +226,7 @@ def create_one_click_payment(
"subclasses of BasePaymentBackend must provide a create_one_click_payment() method."
)
- def create_zero_click_payment(self, order, credit_card_token, installment=None):
+ def create_zero_click_payment(self, order, installment, credit_card_token):
"""
Method used to create a zero click payment from the payment provider.
"""
diff --git a/src/backend/joanie/payment/backends/dummy.py b/src/backend/joanie/payment/backends/dummy.py
index 3efbc601e..1309cd745 100644
--- a/src/backend/joanie/payment/backends/dummy.py
+++ b/src/backend/joanie/payment/backends/dummy.py
@@ -36,11 +36,12 @@ class DummyPaymentBackend(BasePaymentBackend):
name = "dummy"
@staticmethod
- def get_payment_id(order_id):
+ def get_payment_id(installment_id: str):
"""
- Process a payment id according to order id.
+ Process a dummy `payment_id` according to the given input parameter (installment id,
+ or an order id).
"""
- return f"pay_{order_id:s}"
+ return f"pay_{installment_id}"
def _treat_payment(self, resource, data):
"""
@@ -74,7 +75,7 @@ def _treat_payment(self, resource, data):
f"Payment {resource['id']} relies on a non-existing order."
) from error
- installment_id = resource["metadata"].get("installment_id")
+ installment_id = str(resource["metadata"].get("installment_id"))
if data.get("state") == DUMMY_PAYMENT_BACKEND_PAYMENT_STATE_FAILED:
self._do_on_payment_failure(order, installment_id=installment_id)
elif data.get("state") == DUMMY_PAYMENT_BACKEND_PAYMENT_STATE_SUCCESS:
@@ -114,35 +115,49 @@ def _treat_refund(self, resource, amount):
)
@classmethod
- def _send_mail_payment_success(cls, order):
+ def _send_mail_subscription_success(cls, order):
logger.info("Mail is sent to %s from dummy payment", order.owner.email)
- super()._send_mail_payment_success(order)
+ super()._send_mail_subscription_success(order)
+
+ @classmethod
+ def _send_mail_payment_installment_success(
+ cls, order, amount, upcoming_installment
+ ):
+ logger.info("Mail is sent to %s from dummy payment", order.owner.email)
+ super()._send_mail_payment_installment_success(
+ order=order, amount=amount, upcoming_installment=upcoming_installment
+ )
+
+ @classmethod
+ def _send_mail_refused_debit(cls, order, installment_id):
+ logger.info("Mail is sent to %s from dummy payment", order.owner.email)
+ super()._send_mail_refused_debit(order, installment_id)
def _get_payment_data(
self,
order,
- billing_address,
+ installment,
credit_card_token=None,
- installment=None,
+ billing_address=None,
):
"""Build the generic payment object."""
order_id = str(order.id)
- payment_id = self.get_payment_id(order_id)
+ payment_id = self.get_payment_id(installment.get("id"))
notification_url = self.get_notification_url()
payment_info = {
"id": payment_id,
- "amount": int(float(installment["amount"]) * 100)
- if installment
- else int(order.total * 100),
+ "amount": int(installment["amount"].sub_units),
"notification_url": notification_url,
- "metadata": {"order_id": order_id},
+ "metadata": {
+ "order_id": order_id,
+ "installment_id": str(installment["id"]),
+ },
}
if billing_address:
payment_info["billing_address"] = billing_address
if credit_card_token:
payment_info["credit_card_token"] = credit_card_token
- if installment:
- payment_info["metadata"]["installment_id"] = installment["id"]
+
cache.set(payment_id, payment_info)
return {
@@ -154,22 +169,24 @@ def _get_payment_data(
def create_payment(
self,
order,
+ installment,
billing_address=None,
- installment=None,
):
"""
Generate a payment object then store it in the cache.
"""
- return self._get_payment_data(order, billing_address, installment=installment)
+ return self._get_payment_data(
+ order, installment, billing_address=billing_address
+ )
def create_one_click_payment(
- self, order, billing_address, credit_card_token=None, installment=None
+ self, order, installment, credit_card_token, billing_address
):
"""
Call create_payment method and bind a `is_paid` property to payment information.
"""
payment_info = self._get_payment_data(
- order, billing_address, installment=installment
+ order, installment, credit_card_token, billing_address
)
notification_request = APIRequestFactory().post(
reverse("payment_webhook"),
@@ -189,14 +206,15 @@ def create_one_click_payment(
"is_paid": True,
}
- def create_zero_click_payment(
- self, order, credit_card_token=None, installment=None
- ):
+ def create_zero_click_payment(self, order, installment, credit_card_token):
"""
Call create_payment method and bind a `is_paid` property to payment information.
"""
payment_info = self._get_payment_data(
- order, credit_card_token, installment=installment
+ order,
+ installment,
+ credit_card_token,
+ order.main_invoice.recipient_address,
)
notification_request = APIRequestFactory().post(
reverse("payment_webhook"),
diff --git a/src/backend/joanie/payment/backends/lyra/__init__.py b/src/backend/joanie/payment/backends/lyra/__init__.py
index dfa8e7b87..37c43f859 100644
--- a/src/backend/joanie/payment/backends/lyra/__init__.py
+++ b/src/backend/joanie/payment/backends/lyra/__init__.py
@@ -13,7 +13,7 @@
import requests
from rest_framework.parsers import FormParser, JSONParser
-from joanie.core.models import ActivityLog, Address, Order
+from joanie.core.models import ActivityLog, Address, Order, User
from joanie.payment import exceptions
from joanie.payment.backends.base import BasePaymentBackend
from joanie.payment.models import CreditCard, Invoice, Transaction
@@ -63,7 +63,7 @@ def __init__(self, configuration):
api_base_url = self.configuration["api_base_url"]
self.api_url = api_base_url + "/api-payment/V4/"
- def _get_common_payload_data(self, order, billing_address=None, installment=None):
+ def _get_common_payload_data(self, order, installment=None, billing_address=None):
"""
Build post payload data for Lyra API
@@ -71,7 +71,7 @@ def _get_common_payload_data(self, order, billing_address=None, installment=None
"""
payload = {
"currency": settings.DEFAULT_CURRENCY,
- "amount": int(float(installment["amount"]) * 100)
+ "amount": int(installment["amount"].sub_units)
if installment
else int(order.total * 100),
"customer": {
@@ -98,7 +98,7 @@ def _get_common_payload_data(self, order, billing_address=None, installment=None
if installment:
payload["metadata"] = {
- "installment_id": installment["id"],
+ "installment_id": str(installment["id"]),
}
return payload
@@ -214,7 +214,7 @@ def _tokenize_card_for_order(self, order, billing_address):
"""
url = f"{self.api_url}Charge/CreateToken"
- payload = self._get_common_payload_data(order, billing_address)
+ payload = self._get_common_payload_data(order, billing_address=billing_address)
payload["formAction"] = "REGISTER"
payload["strongAuthentication"] = "CHALLENGE_REQUESTED"
del payload["amount"]
@@ -232,7 +232,7 @@ def tokenize_card(self, order=None, billing_address=None, user=None):
return self._tokenize_card_for_user(user)
return self._tokenize_card_for_order(order, billing_address)
- def create_payment(self, order, billing_address, installment=None):
+ def create_payment(self, order, installment, billing_address):
"""
Create a payment object for a given order
@@ -240,15 +240,13 @@ def create_payment(self, order, billing_address, installment=None):
https://docs.lyra.com/fr/rest/V4.0/api/playground/Charge/CreatePayment
"""
url = f"{self.api_url}Charge/CreatePayment"
- payload = self._get_common_payload_data(
- order, billing_address, installment=installment
- )
- payload["formAction"] = "ASK_REGISTER_PAY"
+ payload = self._get_common_payload_data(order, installment, billing_address)
+ payload["formAction"] = "REGISTER_PAY"
return self._get_payment_info(url, payload)
def create_one_click_payment(
- self, order, billing_address, credit_card_token, installment=None
+ self, order, installment, credit_card_token, billing_address
):
"""
Create a one click payment object for a given order
@@ -257,15 +255,13 @@ def create_one_click_payment(
https://docs.lyra.com/fr/rest/V4.0/api/playground/Charge/CreatePayment
"""
url = f"{self.api_url}Charge/CreatePayment"
- payload = self._get_common_payload_data(
- order, billing_address, installment=installment
- )
+ payload = self._get_common_payload_data(order, installment, billing_address)
payload["formAction"] = "PAYMENT"
payload["paymentMethodToken"] = credit_card_token
return self._get_payment_info(url, payload)
- def create_zero_click_payment(self, order, credit_card_token, installment=None):
+ def create_zero_click_payment(self, order, installment, credit_card_token):
"""
Create a zero click payment object for a given order
@@ -274,7 +270,7 @@ def create_zero_click_payment(self, order, credit_card_token, installment=None):
"""
url = f"{self.api_url}Charge/CreatePayment"
- payload = self._get_common_payload_data(order, installment=installment)
+ payload = self._get_common_payload_data(order, installment)
payload["formAction"] = "SILENT"
payload["paymentMethodToken"] = credit_card_token
@@ -298,6 +294,7 @@ def create_zero_click_payment(self, order, credit_card_token, installment=None):
payment = {
"id": answer["transactions"][0]["uuid"],
+ "installment_id": installment["id"],
"amount": D(f"{answer['orderDetails']['orderTotalAmount'] / 100:.2f}"),
"billing_address": {
"address": billing_details["address"],
@@ -309,9 +306,6 @@ def create_zero_click_payment(self, order, credit_card_token, installment=None):
},
}
- if installment:
- payment["installment_id"] = installment["id"]
-
self._do_on_payment_success(
order=order,
payment=payment,
@@ -349,6 +343,9 @@ def handle_notification(self, request):
)
raise exceptions.ParseNotificationFailed() from error
+ if order_id is None:
+ return self._handle_notification_tokenization_card_for_user(answer)
+
try:
order = Order.objects.get(id=order_id)
except Order.DoesNotExist as error:
@@ -422,7 +419,48 @@ def handle_notification(self, request):
payment=payment,
)
else:
- self._do_on_payment_failure(order, installment_id=installment_id)
+ self._do_on_payment_failure(order, installment_id)
+
+ return None
+
+ def _handle_notification_tokenization_card_for_user(self, answer):
+ """
+ When the user has tokenized a card outside an order process, we have to handle it
+ separately as we have no order information.
+ """
+
+ if answer["orderStatus"] != "PAID":
+ # Tokenization has failed, nothing to do.
+ return
+
+ try:
+ user = User.objects.get(id=answer["customer"]["reference"])
+ except User.DoesNotExist as error:
+ message = (
+ "Received notification to tokenize a card for a non-existing user:"
+ f" {answer['customer']['reference']}"
+ )
+ logger.error(message)
+ raise exceptions.TokenizationCardFailed(message) from error
+
+ card_token = answer["transactions"][0]["paymentMethodToken"]
+ transaction_details = answer["transactions"][0]["transactionDetails"]
+ card_details = transaction_details["cardDetails"]
+ card_pan = card_details["pan"]
+ initial_issuer_transaction_identifier = card_details[
+ "initialIssuerTransactionIdentifier"
+ ]
+
+ CreditCard.objects.create(
+ brand=card_details["effectiveBrand"],
+ expiration_month=card_details["expiryMonth"],
+ expiration_year=card_details["expiryYear"],
+ last_numbers=card_pan[-4:], # last 4 digits
+ owner=user,
+ token=card_token,
+ initial_issuer_transaction_identifier=initial_issuer_transaction_identifier,
+ payment_provider=self.name,
+ )
def delete_credit_card(self, credit_card):
"""Delete a credit card from Lyra"""
diff --git a/src/backend/joanie/payment/backends/payplug/__init__.py b/src/backend/joanie/payment/backends/payplug/__init__.py
index 1fb675903..c5f884375 100644
--- a/src/backend/joanie/payment/backends/payplug/__init__.py
+++ b/src/backend/joanie/payment/backends/payplug/__init__.py
@@ -37,13 +37,11 @@ def __init__(self, configuration):
payplug.set_secret_key(self.configuration["secret_key"])
payplug.set_api_version(self.api_version)
- def _get_payment_data(self, order, billing_address, installment=None):
+ def _get_payment_data(self, order, installment, billing_address):
"""Build the generic payment object"""
payment_data = {
- "amount": int(float(installment["amount"]) * 100)
- if installment
- else int(order.total * 100),
+ "amount": int(installment["amount"].sub_units),
"currency": settings.DEFAULT_CURRENCY,
"billing": {
"first_name": billing_address["first_name"],
@@ -60,12 +58,9 @@ def _get_payment_data(self, order, billing_address, installment=None):
"notification_url": self.get_notification_url(),
"metadata": {
"order_id": str(order.id),
+ "installment_id": str(installment["id"]),
},
}
-
- if installment:
- payment_data["metadata"]["installment_id"] = installment["id"]
-
return payment_data
def _treat_payment(self, resource):
@@ -146,13 +141,11 @@ def _treat_refund(self, resource):
refund_reference=resource.id,
)
- def create_payment(self, order, billing_address, installment=None):
+ def create_payment(self, order, installment, billing_address):
"""
Create a payment object for a given order
"""
- payment_data = self._get_payment_data(
- order, billing_address, installment=installment
- )
+ payment_data = self._get_payment_data(order, installment, billing_address)
payment_data["allow_save_card"] = True
try:
@@ -167,7 +160,7 @@ def create_payment(self, order, billing_address, installment=None):
}
def create_one_click_payment(
- self, order, billing_address, credit_card_token, installment=None
+ self, order, installment, credit_card_token, billing_address
):
"""
Create a one click payment
@@ -176,9 +169,7 @@ def create_one_click_payment(
and we have to notify frontend that payment has not been paid.
"""
# - Build the payment object
- payment_data = self._get_payment_data(
- order, billing_address, installment=installment
- )
+ payment_data = self._get_payment_data(order, installment, billing_address)
payment_data["allow_save_card"] = False
payment_data["initiator"] = "PAYER"
payment_data["payment_method"] = credit_card_token
@@ -186,7 +177,7 @@ def create_one_click_payment(
try:
payment = payplug.Payment.create(**payment_data)
except BadRequest:
- return self.create_payment(order, billing_address)
+ return self.create_payment(order, installment, billing_address)
return {
"payment_id": payment.id,
@@ -195,7 +186,7 @@ def create_one_click_payment(
"is_paid": payment.is_paid,
}
- def create_zero_click_payment(self, order, credit_card_token, installment=None):
+ def create_zero_click_payment(self, order, installment, credit_card_token):
"""
Method used to create a zero click payment from payplug.
"""
diff --git a/src/backend/joanie/payment/exceptions.py b/src/backend/joanie/payment/exceptions.py
index cda4d3cdc..801da430d 100644
--- a/src/backend/joanie/payment/exceptions.py
+++ b/src/backend/joanie/payment/exceptions.py
@@ -31,6 +31,14 @@ class RegisterPaymentFailed(APIException):
default_code = "register_payment_failed"
+class TokenizationCardFailed(APIException):
+ """Exception triggered when registering payment failed."""
+
+ status_code = HTTPStatus.BAD_REQUEST
+ default_detail = _("Cannot register this payment.")
+ default_code = "register_payment_failed"
+
+
class RefundPaymentFailed(APIException):
"""Exception triggered when refunding payment failed."""
diff --git a/src/backend/joanie/payment/factories.py b/src/backend/joanie/payment/factories.py
index 1a11f468a..7ca6dd8a1 100644
--- a/src/backend/joanie/payment/factories.py
+++ b/src/backend/joanie/payment/factories.py
@@ -18,6 +18,7 @@ class Meta:
"""Meta"""
model = models.CreditCard
+ django_get_or_create = ("token",)
brand = factory.Faker("credit_card_provider")
expiration_month = factory.Faker("credit_card_expire", date_format="%m")
@@ -27,6 +28,7 @@ class Meta:
title = factory.Faker("name")
token = factory.Sequence(lambda k: f"card_{k:022d}")
payment_provider = "dummy"
+ initial_issuer_transaction_identifier = factory.Faker("uuid4")
class InvoiceFactory(factory.django.DjangoModelFactory):
diff --git a/src/backend/joanie/settings.py b/src/backend/joanie/settings.py
index d06b263cb..24106f6ca 100755
--- a/src/backend/joanie/settings.py
+++ b/src/backend/joanie/settings.py
@@ -205,6 +205,7 @@ class Base(Configuration):
"django.contrib.sites",
"django.contrib.messages",
"django.contrib.staticfiles",
+ "django.contrib.humanize",
# Third party apps
"admin_auto_filters",
"django_object_actions",
@@ -420,7 +421,7 @@ class Base(Configuration):
),
}
JOANIE_PAYMENT_SCHEDULE_LIMITS = values.DictValue(
- {200: (30, 70), 500: (30, 35, 35), 1000: (30, 25, 25, 20)},
+ {150: (100,), 200: (30, 70), 500: (30, 35, 35), 1000: (30, 25, 25, 20)},
environ_name="JOANIE_PAYMENT_SCHEDULE_LIMITS",
environ_prefix=None,
)
@@ -437,9 +438,22 @@ class Base(Configuration):
environ_name="JOANIE_WITHDRAWAL_PERIOD_DAYS",
environ_prefix=None,
)
+ # Email for installment payment
+ # Add here the dashboard link of orders
+ JOANIE_DASHBOARD_ORDER_LINK = values.Value(
+ None,
+ environ_name="JOANIE_DASHBOARD_ORDER_LINK",
+ environ_prefix=None,
+ )
+ # Add here the number of days ahead before notifying a user
+ # on his next installment debit
+ JOANIE_INSTALLMENT_REMINDER_PERIOD_DAYS = values.Value(
+ 2,
+ environ_name="JOANIE_INSTALLMENT_REMINDER_DAYS_BEFORE",
+ environ_prefix=None,
+ )
# CORS
-
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(False)
CORS_ALLOWED_ORIGINS = values.ListValue([])
@@ -738,6 +752,16 @@ class Test(Base):
JOANIE_ENROLLMENT_GRADE_CACHE_TTL = 0
JOANIE_DOCUMENT_ISSUER_CONTEXT_PROCESSORS = {"contract_definition": []}
+ JOANIE_PAYMENT_SCHEDULE_LIMITS = values.DictValue(
+ {0: (30, 70)},
+ environ_name="JOANIE_PAYMENT_SCHEDULE_LIMITS",
+ environ_prefix=None,
+ )
+
+ JOANIE_DASHBOARD_ORDER_LINK = (
+ "http://localhost:8070/dashboard/courses/orders/:orderId/"
+ )
+
LOGGING = values.DictValue(
{
"version": 1,
diff --git a/src/backend/joanie/signature/backends/base.py b/src/backend/joanie/signature/backends/base.py
index 878989d8d..991864f0c 100644
--- a/src/backend/joanie/signature/backends/base.py
+++ b/src/backend/joanie/signature/backends/base.py
@@ -7,8 +7,6 @@
from django.core.exceptions import ValidationError
from django.utils import timezone as django_timezone
-from sentry_sdk import capture_exception
-
from joanie.core import models
logger = getLogger(__name__)
@@ -66,14 +64,7 @@ def confirm_student_signature(self, reference):
contract.student_signed_on = django_timezone.now()
contract.save()
- # The student has signed the contract, we can now try to automatically enroll
- # it to single course runs opened for enrollment.
- try:
- # ruff : noqa : BLE001
- # pylint: disable=broad-exception-caught
- contract.order.enroll_user_to_course_run()
- except Exception as error:
- capture_exception(error)
+ contract.order.flow.update()
logger.info("Student signed the contract '%s'", contract.id)
@@ -160,3 +151,12 @@ def update_signatories(self, reference_id: str, all_signatories: bool):
"subclasses of BaseSignatureBackend must provide a "
"update_signatories() method."
)
+
+ def get_signature_state(self, reference_id: str):
+ """
+ Get the signature status of a contract to see who has signed the document so far.
+ """
+ raise NotImplementedError(
+ "subclasses of BaseSignatureBackend must provide a "
+ "get_signature_state() method."
+ )
diff --git a/src/backend/joanie/signature/backends/dummy.py b/src/backend/joanie/signature/backends/dummy.py
index c3358cf0d..6289f7947 100644
--- a/src/backend/joanie/signature/backends/dummy.py
+++ b/src/backend/joanie/signature/backends/dummy.py
@@ -12,6 +12,7 @@
from joanie.core.utils import contract_definition as contract_definition_utility
from joanie.core.utils import issuers
+from ...core.models import Contract
from .base import BaseSignatureBackend
logger = getLogger(__name__)
@@ -39,22 +40,20 @@ def submit_for_signature(self, title: str, file_bytes: bytes, order: models.Orde
def get_signature_invitation_link(self, recipient_email: str, reference_ids: list):
"""
- Dummy method that prepares an invitation link, and it triggers an email notifying that the
- file is available to download to the signer by email.
+ Dummy method that prepares an invitation link. The invitation link contains
+ the contract reference and the targeted event type. Those information can be
+ used by API consumers to manually send the notification event
+ to confirm the signature.
"""
-
- contracts = models.Contract.objects.filter(
- signature_backend_reference__in=reference_ids
- ).only("organization_signed_on", "signature_backend_reference")
-
- for contract in contracts:
- event_type = "finished" if contract.student_signed_on else "signed"
- reference = contract.signature_backend_reference
- self.handle_notification({"event_type": event_type, "reference": reference})
-
- self._send_email(recipient_email=recipient_email, reference_id=reference)
-
- return f"https://dummysignaturebackend.fr/?requestToken={reference_ids[0]}#requestId=req"
+ reference_id = reference_ids[0]
+ contract = Contract.objects.get(signature_backend_reference=reference_id)
+ event_target = (
+ "finished" if contract.student_signed_on is not None else "signed"
+ )
+ return (
+ f"https://dummysignaturebackend.fr/?reference={reference_id}"
+ f"&eventTarget={event_target}"
+ )
def delete_signing_procedure(self, reference_id: str):
"""
@@ -73,8 +72,8 @@ def handle_notification(self, request):
When the event type is "finished", it updates the field of 'organization_signed_on' of the
contract with a timestamp.
"""
- event_type = request.get("event_type")
- reference_id = request.get("reference")
+ event_type = request.data.get("event_type")
+ reference_id = request.data.get("reference")
if event_type == "signed":
self.confirm_student_signature(reference_id)
@@ -148,3 +147,22 @@ def update_signatories(self, reference_id: str, all_signatories: bool) -> str:
raise ValidationError(f"The contract {contract.id} is already fully signed")
return contract.signature_backend_reference
+
+ def get_signature_state(self, reference_id: str) -> int:
+ """
+ Dummy method that returns the status of a document in the signing process.
+ It indicates whether the student and the organization have signed.
+ """
+ if not reference_id.startswith(self.prefix_workflow):
+ raise ValidationError(f"The reference does not exist: {reference_id}.")
+ try:
+ contract = Contract.objects.get(signature_backend_reference=reference_id)
+ except Contract.DoesNotExist as exception:
+ raise ValidationError(
+ f"Contract with reference id {reference_id} does not exist."
+ ) from exception
+
+ return {
+ "student": bool(contract.student_signed_on),
+ "organization": bool(contract.organization_signed_on),
+ }
diff --git a/src/backend/joanie/signature/backends/lex_persona.py b/src/backend/joanie/signature/backends/lex_persona.py
index f4bbae81e..02e743021 100644
--- a/src/backend/joanie/signature/backends/lex_persona.py
+++ b/src/backend/joanie/signature/backends/lex_persona.py
@@ -667,3 +667,37 @@ def update_signatories(self, reference_id: str, all_signatories: bool) -> str:
)
return response.json()["id"]
+
+ def get_signature_state(self, reference_id: str) -> dict:
+ """
+ Get the signature progress of a signing procedure. It returns a dictionary
+ indicating whether the student and the organization have signed.
+ """
+ timeout = settings.JOANIE_SIGNATURE_TIMEOUT
+ base_url = self.get_setting("BASE_URL")
+ token = self.get_setting("TOKEN")
+ url = f"{base_url}/api/workflows/{reference_id}/"
+ headers = {"Authorization": f"Bearer {token}"}
+
+ response = requests.get(url, headers=headers, timeout=timeout)
+
+ if not response.ok:
+ logger.error(
+ "Lex Persona: Unable to retrieve the signature procedure"
+ " the reference does not exist %s, reason: %s",
+ reference_id,
+ response.json(),
+ extra={
+ "url": url,
+ "response": response.json(),
+ },
+ )
+ raise exceptions.SignatureProcedureNotFound(
+ "Lex Persona: Unable to retrieve the signature procedure"
+ f" the reference does not exist {reference_id}"
+ )
+
+ return {
+ "student": response.json().get("steps")[0].get("isFinished"),
+ "organization": response.json().get("steps")[-1].get("isFinished"),
+ }
diff --git a/src/backend/joanie/signature/exceptions.py b/src/backend/joanie/signature/exceptions.py
index 1f325af67..a06d2b88b 100644
--- a/src/backend/joanie/signature/exceptions.py
+++ b/src/backend/joanie/signature/exceptions.py
@@ -67,3 +67,14 @@ class DeleteSignatureProcedureFailed(APIException):
status_code = HTTPStatus.BAD_REQUEST
default_detail = _("Cannot delete the signature procedure.")
default_code = "delete_signature_procedure_failed"
+
+
+class SignatureProcedureNotFound(APIException):
+ """
+ Exception triggered when retrieving the signing procedure from the signature
+ provider fails
+ """
+
+ status_code = HTTPStatus.NOT_FOUND
+ default_detail = _("The reference of signing procedure does not exist.")
+ default_code = "signature_procedure_not_found"
diff --git a/src/backend/joanie/tests/core/admin/test_certificate.py b/src/backend/joanie/tests/core/admin/test_certificate.py
index 33bd48008..62a3b750d 100644
--- a/src/backend/joanie/tests/core/admin/test_certificate.py
+++ b/src/backend/joanie/tests/core/admin/test_certificate.py
@@ -9,7 +9,7 @@
from django.test import TestCase
from django.urls import reverse
-from joanie.core.enums import ORDER_STATE_VALIDATED
+from joanie.core.enums import ORDER_STATE_COMPLETED
from joanie.core.factories import (
CourseProductRelationFactory,
EnrollmentCertificateFactory,
@@ -47,7 +47,7 @@ def setUp(self):
owner=self.learner_1,
product=cpr.product,
course=cpr.course,
- state=ORDER_STATE_VALIDATED,
+ state=ORDER_STATE_COMPLETED,
)
self.certificate_order = OrderCertificateFactory(order=order)
diff --git a/src/backend/joanie/tests/core/api/admin/course_product_relations/test_generate_certificates.py b/src/backend/joanie/tests/core/api/admin/course_product_relations/test_generate_certificates.py
index c4ccf37ac..238425453 100644
--- a/src/backend/joanie/tests/core/api/admin/course_product_relations/test_generate_certificates.py
+++ b/src/backend/joanie/tests/core/api/admin/course_product_relations/test_generate_certificates.py
@@ -209,7 +209,7 @@ def test_admin_api_course_product_relation_generate_certificates_authenticated_c
course=cpr.course,
)
for order in orders:
- order.submit()
+ order.init_flow()
self.assertFalse(Certificate.objects.exists())
@@ -278,7 +278,7 @@ def test_admin_api_course_product_relation_generate_certificates_authenticated_t
course=cpr.course,
)
for order in orders_in_past:
- order.submit()
+ order.init_flow()
factories.OrderCertificateFactory(order=order)
self.assertEqual(Certificate.objects.count(), 5)
@@ -290,7 +290,7 @@ def test_admin_api_course_product_relation_generate_certificates_authenticated_t
course=cpr.course,
)
for order in orders:
- order.submit()
+ order.init_flow()
mock_generate_certificates_task.delay.return_value = ""
@@ -363,7 +363,7 @@ def test_api_admin_course_product_relation_generate_certificates_exception_by_ce
course=cpr.course,
)
for order in orders:
- order.submit()
+ order.init_flow()
mock_generate_certificates_task.delay.side_effect = Exception(
"Some error occured with Celery"
@@ -579,7 +579,7 @@ def test_api_admin_course_product_relation_check_certificates_generation_process
course=cpr.course,
)
for order in orders:
- order.submit()
+ order.init_flow()
self.assertFalse(Certificate.objects.exists())
@@ -650,7 +650,7 @@ def test_api_admin_course_product_relation_check_certificates_generation_complet
course=cpr.course,
)
for order in orders:
- order.submit()
+ order.init_flow()
self.assertFalse(Certificate.objects.exists())
diff --git a/src/backend/joanie/tests/core/api/order/test_abort.py b/src/backend/joanie/tests/core/api/order/test_abort.py
deleted file mode 100644
index 286e08927..000000000
--- a/src/backend/joanie/tests/core/api/order/test_abort.py
+++ /dev/null
@@ -1,146 +0,0 @@
-"""Tests for the Order abort API."""
-
-from http import HTTPStatus
-from unittest import mock
-
-from django.core.cache import cache
-
-from joanie.core import enums, factories, models
-from joanie.payment.backends.dummy import DummyPaymentBackend
-from joanie.payment.factories import BillingAddressDictFactory
-from joanie.tests.base import BaseAPITestCase
-
-
-class OrderAbortApiTest(BaseAPITestCase):
- """Test the API of the Order abort endpoint."""
-
- maxDiff = None
-
- def setUp(self):
- """Clear cache after each tests"""
- cache.clear()
-
- def test_api_order_abort_anonymous(self):
- """An anonymous user should not be allowed to abort an order"""
- order = factories.OrderFactory()
-
- response = self.client.post(f"/api/v1.0/orders/{order.id}/abort/")
-
- self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED)
- self.assertDictEqual(
- response.json(), {"detail": "Authentication credentials were not provided."}
- )
-
- def test_api_order_abort_authenticated_user_not_owner(self):
- """
- An authenticated user which is not the owner of the order should not be
- allowed to abort the order.
- """
- user = factories.UserFactory()
- order = factories.OrderFactory()
-
- token = self.generate_token_from_user(user)
- response = self.client.post(
- f"/api/v1.0/orders/{order.id}/abort/", HTTP_AUTHORIZATION=f"Bearer {token}"
- )
-
- self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND)
-
- def test_api_order_abort_authenticated_forbidden_validated(self):
- """
- An authenticated user which is the owner of the order should not be able
- to abort the order if it is validated.
- """
- user = factories.UserFactory()
- product = factories.ProductFactory(price=0.00)
- order = factories.OrderFactory(
- owner=user, product=product, state=enums.ORDER_STATE_VALIDATED
- )
-
- token = self.generate_token_from_user(user)
- response = self.client.post(
- f"/api/v1.0/orders/{order.id}/abort/", HTTP_AUTHORIZATION=f"Bearer {token}"
- )
-
- self.assertEqual(response.status_code, HTTPStatus.UNPROCESSABLE_ENTITY)
- order.refresh_from_db()
- self.assertEqual(order.state, enums.ORDER_STATE_VALIDATED)
-
- @mock.patch.object(
- DummyPaymentBackend,
- "abort_payment",
- side_effect=DummyPaymentBackend().abort_payment,
- )
- def test_api_order_abort(self, mock_abort_payment):
- """
- An authenticated user which is the owner of the order should be able to abort
- the order if it is draft and abort the related payment if a payment_id is
- provided.
- """
- user = factories.UserFactory()
- product = factories.ProductFactory()
- pc_relation = product.course_relations.first()
- course = pc_relation.course
- organization = pc_relation.organizations.first()
- billing_address = BillingAddressDictFactory()
-
- # - Create an order and its related payment
- token = self.generate_token_from_user(user)
- data = {
- "organization_id": str(organization.id),
- "product_id": str(product.id),
- "course_code": course.code,
- "billing_address": billing_address,
- "has_consent_to_terms": True,
- }
- response = self.client.post(
- "/api/v1.0/orders/",
- data=data,
- content_type="application/json",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
-
- self.assertEqual(response.status_code, HTTPStatus.CREATED)
- order = models.Order.objects.get(id=response.json()["id"])
- response = self.client.patch(
- f"/api/v1.0/orders/{order.id}/submit/",
- data=data,
- content_type="application/json",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
- content = response.json()
- payment_id = content["payment_info"]["payment_id"]
- order.refresh_from_db()
- # - A draft order should have been created...
- self.assertEqual(response.status_code, HTTPStatus.CREATED)
- self.assertEqual(order.state, enums.ORDER_STATE_SUBMITTED)
-
- # - ... with a payment
- self.assertIsNotNone(cache.get(payment_id))
-
- # - User asks to abort the order
- response = self.client.post(
- f"/api/v1.0/orders/{order.id}/abort/",
- data={"payment_id": payment_id},
- content_type="application/json",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
-
- self.assertEqual(response.status_code, HTTPStatus.NO_CONTENT)
-
- # - Order should have been canceled ...
- order.refresh_from_db()
- self.assertEqual(order.state, enums.ORDER_STATE_PENDING)
-
- # - and its related payment should have been aborted.
- mock_abort_payment.assert_called_once_with(payment_id)
- self.assertIsNone(cache.get(payment_id))
-
- # Cancel the order
- response = self.client.post(
- f"/api/v1.0/orders/{order.id}/cancel/",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
- self.assertEqual(response.status_code, HTTPStatus.NO_CONTENT)
- order.refresh_from_db()
- self.assertEqual(order.state, enums.ORDER_STATE_CANCELED)
diff --git a/src/backend/joanie/tests/core/api/order/test_cancel.py b/src/backend/joanie/tests/core/api/order/test_cancel.py
index 340ad6a94..68e60644d 100644
--- a/src/backend/joanie/tests/core/api/order/test_cancel.py
+++ b/src/backend/joanie/tests/core/api/order/test_cancel.py
@@ -5,7 +5,6 @@
from django.core.cache import cache
from joanie.core import enums, factories
-from joanie.payment.factories import BillingAddressDictFactory
from joanie.tests.base import BaseAPITestCase
@@ -53,72 +52,57 @@ def test_api_order_cancel_authenticated_not_owned(self):
user = factories.UserFactory()
token = self.generate_token_from_user(user)
order = factories.OrderFactory()
- order.submit(
- billing_address=BillingAddressDictFactory(),
- )
response = self.client.post(
f"/api/v1.0/orders/{order.id}/cancel/",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
order.refresh_from_db()
self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND)
- self.assertEqual(order.state, enums.ORDER_STATE_SUBMITTED)
+ self.assertEqual(order.state, enums.ORDER_STATE_DRAFT)
def test_api_order_cancel_authenticated_owned(self):
"""
- User should able to cancel owned orders as long as they are not
- validated
+ User should be able to cancel owned orders as long as they are not
+ completed
"""
user = factories.UserFactory()
token = self.generate_token_from_user(user)
- order_draft = factories.OrderFactory(owner=user, state=enums.ORDER_STATE_DRAFT)
- order_pending = factories.OrderFactory(
- owner=user, state=enums.ORDER_STATE_PENDING
- )
- order_submitted = factories.OrderFactory(
- owner=user, state=enums.ORDER_STATE_SUBMITTED
- )
-
- # Canceling draft order
- response = self.client.post(
- f"/api/v1.0/orders/{order_draft.id}/cancel/",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
- order_draft.refresh_from_db()
- self.assertEqual(response.status_code, HTTPStatus.NO_CONTENT)
- self.assertEqual(order_draft.state, enums.ORDER_STATE_CANCELED)
-
- # Canceling pending order
- response = self.client.post(
- f"/api/v1.0/orders/{order_pending.id}/cancel/",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
- order_pending.refresh_from_db()
- self.assertEqual(response.status_code, HTTPStatus.NO_CONTENT)
- self.assertEqual(order_pending.state, enums.ORDER_STATE_CANCELED)
-
- # Canceling submitted order
- response = self.client.post(
- f"/api/v1.0/orders/{order_submitted.id}/cancel/",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
- order_submitted.refresh_from_db()
- self.assertEqual(response.status_code, HTTPStatus.NO_CONTENT)
- self.assertEqual(order_submitted.state, enums.ORDER_STATE_CANCELED)
+ for state, _ in enums.ORDER_STATE_CHOICES:
+ with self.subTest(state=state):
+ order = factories.OrderFactory(owner=user, state=state)
+ response = self.client.post(
+ f"/api/v1.0/orders/{order.id}/cancel/",
+ HTTP_AUTHORIZATION=f"Bearer {token}",
+ )
+ order.refresh_from_db()
+ if state == enums.ORDER_STATE_COMPLETED:
+ self.assertContains(
+ response,
+ "Cannot cancel a completed order",
+ status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
+ )
+ self.assertEqual(order.state, enums.ORDER_STATE_COMPLETED)
+ else:
+ self.assertEqual(response.status_code, HTTPStatus.NO_CONTENT)
+ self.assertEqual(order.state, enums.ORDER_STATE_CANCELED)
def test_api_order_cancel_authenticated_validated(self):
"""
- User should not able to cancel already validated order
+ User should not able to cancel already completed order
"""
user = factories.UserFactory()
token = self.generate_token_from_user(user)
order_validated = factories.OrderFactory(
- owner=user, state=enums.ORDER_STATE_VALIDATED
+ owner=user, state=enums.ORDER_STATE_COMPLETED
)
response = self.client.post(
f"/api/v1.0/orders/{order_validated.id}/cancel/",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
order_validated.refresh_from_db()
- self.assertEqual(response.status_code, HTTPStatus.UNPROCESSABLE_ENTITY)
- self.assertEqual(order_validated.state, enums.ORDER_STATE_VALIDATED)
+ self.assertContains(
+ response,
+ "Cannot cancel a completed order",
+ status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
+ )
+ self.assertEqual(order_validated.state, enums.ORDER_STATE_COMPLETED)
diff --git a/src/backend/joanie/tests/core/api/order/test_create.py b/src/backend/joanie/tests/core/api/order/test_create.py
index 0ebec060b..aa83b9210 100644
--- a/src/backend/joanie/tests/core/api/order/test_create.py
+++ b/src/backend/joanie/tests/core/api/order/test_create.py
@@ -9,14 +9,10 @@
from django.conf import settings
from joanie.core import enums, factories, models
+from joanie.core.api.client import OrderViewSet
+from joanie.core.models import CourseState
from joanie.core.serializers import fields
-from joanie.payment.backends.dummy import DummyPaymentBackend
-from joanie.payment.exceptions import CreatePaymentFailed
-from joanie.payment.factories import (
- BillingAddressDictFactory,
- CreditCardFactory,
- InvoiceFactory,
-)
+from joanie.payment.factories import BillingAddressDictFactory, CreditCardFactory
from joanie.tests.base import BaseAPITestCase
@@ -25,6 +21,47 @@ class OrderCreateApiTest(BaseAPITestCase):
maxDiff = None
+ def _get_fee_order_data(self, **kwargs):
+ """Return a fee order linked to a course."""
+ product = factories.ProductFactory(price=10.00)
+ billing_address = BillingAddressDictFactory()
+ return {
+ **kwargs,
+ "has_consent_to_terms": True,
+ "product_id": str(product.id),
+ "course_code": product.courses.first().code,
+ "billing_address": billing_address,
+ }
+
+ def _get_free_order_data(self, **kwargs):
+ """Return a free order."""
+ product = factories.ProductFactory(price=0.00)
+
+ return {
+ **kwargs,
+ "has_consent_to_terms": True,
+ "product_id": str(product.id),
+ "course_code": product.courses.first().code,
+ }
+
+ def _get_fee_enrollment_order_data(self, user, **kwargs):
+ """Return a fee order linked to an enrollment."""
+ relation = factories.CourseProductRelationFactory(
+ product__type=enums.PRODUCT_TYPE_CERTIFICATE
+ )
+ enrollment = factories.EnrollmentFactory(
+ user=user, course_run__course=relation.course
+ )
+ billing_address = BillingAddressDictFactory()
+
+ return {
+ **kwargs,
+ "has_consent_to_terms": True,
+ "enrollment_id": str(enrollment.id),
+ "product_id": str(relation.product.id),
+ "billing_address": billing_address,
+ }
+
def test_api_order_create_anonymous(self):
"""Anonymous users should not be able to create an order."""
product = factories.ProductFactory()
@@ -60,7 +97,6 @@ def test_api_order_create_authenticated_for_course_success(self, _mock_thumbnail
"course_code": course.code,
"organization_id": str(organization.id),
"product_id": str(product.id),
- "has_consent_to_terms": True,
}
token = self.get_user_token("panoramix")
@@ -90,6 +126,7 @@ def test_api_order_create_authenticated_for_course_success(self, _mock_thumbnail
"cover": "_this_field_is_mocked",
},
"created_on": order.created_on.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
+ "credit_card_id": None,
"enrollment": None,
"main_invoice_reference": None,
"order_group_id": None,
@@ -119,7 +156,7 @@ def test_api_order_create_authenticated_for_course_success(self, _mock_thumbnail
},
"owner": "panoramix",
"product_id": str(product.id),
- "state": "draft",
+ "state": enums.ORDER_STATE_COMPLETED,
"total": float(product.price),
"total_currency": settings.DEFAULT_CURRENCY,
"target_enrollments": [],
@@ -173,13 +210,6 @@ def test_api_order_create_authenticated_for_course_success(self, _mock_thumbnail
},
)
- with self.assertNumQueries(28):
- response = self.client.patch(
- f"/api/v1.0/orders/{order.id}/submit/",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
- self.assertEqual(response.status_code, HTTPStatus.CREATED)
-
# user has been created
self.assertEqual(models.User.objects.count(), 1)
user = models.User.objects.get()
@@ -187,7 +217,6 @@ def test_api_order_create_authenticated_for_course_success(self, _mock_thumbnail
self.assertEqual(
list(order.target_courses.order_by("product_relations")), target_courses
)
- self.assertDictEqual(response.json(), {"payment_info": None})
@mock.patch.object(
fields.ThumbnailDetailField,
@@ -211,7 +240,6 @@ def test_api_order_create_authenticated_for_enrollment_success(
"enrollment_id": str(enrollment.id),
"organization_id": str(organization.id),
"product_id": str(product.id),
- "has_consent_to_terms": True,
}
token = self.generate_token_from_user(enrollment.user)
@@ -222,6 +250,7 @@ def test_api_order_create_authenticated_for_enrollment_success(
HTTP_AUTHORIZATION=f"Bearer {token}",
)
+ enrollment.refresh_from_db()
self.assertEqual(response.status_code, HTTPStatus.CREATED)
# order has been created
self.assertEqual(models.Order.objects.count(), 1)
@@ -243,6 +272,7 @@ def test_api_order_create_authenticated_for_enrollment_success(
"course": None,
"payment_schedule": None,
"created_on": order.created_on.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
+ "credit_card_id": None,
"enrollment": {
"course_run": {
"course": {
@@ -318,7 +348,7 @@ def test_api_order_create_authenticated_for_enrollment_success(
},
"owner": enrollment.user.username,
"product_id": str(product.id),
- "state": "draft",
+ "state": enums.ORDER_STATE_COMPLETED,
"total": float(product.price),
"total_currency": settings.DEFAULT_CURRENCY,
"target_enrollments": [],
@@ -380,7 +410,6 @@ def test_api_order_create_authenticated_for_enrollment_not_owner(
"enrollment_id": str(enrollment.id),
"organization_id": str(organization.id),
"product_id": str(product.id),
- "has_consent_to_terms": True,
}
token = self.get_user_token("panoramix")
@@ -404,8 +433,8 @@ def test_api_order_create_authenticated_for_enrollment_not_owner(
def test_api_order_create_submit_authenticated_organization_not_passed(self):
"""
- It should be possible to create an order without passing an organization if there are
- none linked to the product, but be impossible to submit
+ It should not be possible to create an order without passing an organization if there are
+ none linked to the product.
"""
target_course = factories.CourseFactory()
course = factories.CourseFactory()
@@ -419,7 +448,6 @@ def test_api_order_create_submit_authenticated_organization_not_passed(self):
data = {
"course_code": course.code,
"product_id": str(product.id),
- "has_consent_to_terms": True,
}
token = self.get_user_token("panoramix")
@@ -430,28 +458,16 @@ def test_api_order_create_submit_authenticated_organization_not_passed(self):
HTTP_AUTHORIZATION=f"Bearer {token}",
)
- self.assertEqual(response.status_code, HTTPStatus.CREATED)
- order_id = response.json()["id"]
- self.assertTrue(models.Order.objects.filter(id=order_id).exists())
- response = self.client.patch(
- f"/api/v1.0/orders/{order_id}/submit/",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)
self.assertEqual(
- models.Order.objects.get(id=order_id).state, enums.ORDER_STATE_DRAFT
- )
- self.assertDictEqual(
response.json(),
- {
- "__all__": ["Order should have an organization if not in draft state"],
- },
+ [" 'Assign' transition conditions have not been met"],
)
def test_api_order_create_authenticated_organization_not_passed_one(self):
"""
- It should be possible to create then submit an order without passing
- an organization if there is only one linked to the product.
+ It should be possible to create an order without passing
+ an organization. If there is only one linked to the product, it should be assigned.
"""
target_course = factories.CourseFactory()
product = factories.ProductFactory(target_courses=[target_course], price=0.00)
@@ -461,7 +477,6 @@ def test_api_order_create_authenticated_organization_not_passed_one(self):
data = {
"course_code": course.code,
"product_id": str(product.id),
- "has_consent_to_terms": True,
}
token = self.get_user_token("panoramix")
@@ -479,15 +494,8 @@ def test_api_order_create_authenticated_organization_not_passed_one(self):
models.Order.objects.filter(
organization__isnull=True, course=course
).count(),
- 1,
- )
-
- response = self.client.patch(
- f"/api/v1.0/orders/{response.json()['id']}/submit/",
- content_type="application/json",
- HTTP_AUTHORIZATION=f"Bearer {token}",
+ 0,
)
-
self.assertEqual(
models.Order.objects.filter(
organization=organization, course=course
@@ -530,7 +538,6 @@ def test_api_order_create_authenticated_organization_passed_several(self):
data = {
"course_code": course.code,
"product_id": str(product.id),
- "has_consent_to_terms": True,
}
token = self.get_user_token("panoramix")
@@ -542,18 +549,154 @@ def test_api_order_create_authenticated_organization_passed_several(self):
)
order_id = response.json()["id"]
-
- response = self.client.patch(
- f"/api/v1.0/orders/{order_id}/submit/",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
-
- self.assertEqual(response.status_code, HTTPStatus.CREATED)
self.assertEqual(models.Order.objects.count(), 10) # 9 + 1
# The chosen organization should be one of the organizations with the lowest order count
organization_id = models.Order.objects.get(id=order_id).organization.id
self.assertEqual(counter[str(organization_id)], min(counter.values()))
+ def test_api_order_create_should_auto_assign_organization(self):
+ """
+ On create request, if the related order has no organization linked yet, the one
+ implied in the course product organization with the least order should be
+ assigned.
+ """
+ user = factories.UserFactory()
+ token = self.generate_token_from_user(user)
+
+ orders_data = [
+ self._get_free_order_data(),
+ self._get_fee_order_data(),
+ self._get_fee_enrollment_order_data(user),
+ ]
+
+ for data in orders_data:
+ response = self.client.post(
+ "/api/v1.0/orders/",
+ data=data,
+ content_type="application/json",
+ HTTP_AUTHORIZATION=f"Bearer {token}",
+ )
+
+ order_id = response.json()["id"]
+ order = models.Order.objects.get(id=order_id)
+ self.assertEqual(response.status_code, HTTPStatus.CREATED)
+ # Now order should have an organization set
+ self.assertIsNotNone(order.organization)
+
+ @mock.patch.object(
+ OrderViewSet, "_get_organization_with_least_active_orders", return_value=None
+ )
+ def test_api_order_create_should_auto_assign_organization_if_needed(
+ self, mocked_round_robin
+ ):
+ """
+ Order should have organization auto assigned only on submit if it has
+ not already one linked.
+ """
+ user = factories.UserFactory()
+ token = self.generate_token_from_user(user)
+
+ # Auto assignment should have been triggered if order has no organization linked
+ # order = factories.OrderFactory(owner=user, organization=None)
+ # self.client.patch(
+ # f"/api/v1.0/orders/{order.id}/submit/",
+ # content_type="application/json",
+ # data={"billing_address": BillingAddressDictFactory()},
+ # HTTP_AUTHORIZATION=f"Bearer {token}",
+ # )
+ data = self._get_free_order_data()
+ self.client.post(
+ "/api/v1.0/orders/",
+ data=data,
+ content_type="application/json",
+ HTTP_AUTHORIZATION=f"Bearer {token}",
+ )
+
+ mocked_round_robin.assert_called_once()
+
+ mocked_round_robin.reset_mock()
+
+ # Auto assignment should not have been
+ # triggered if order already has an organization linked
+ # order = factories.OrderFactory(owner=user)
+ # self.client.patch(
+ # f"/api/v1.0/orders/{order.id}/submit/",
+ # content_type="application/json",
+ # data={"billing_address": BillingAddressDictFactory()},
+ # HTTP_AUTHORIZATION=f"Bearer {token}",
+ # )
+ organization = models.Organization.objects.get()
+ data.update(organization_id=str(organization.id))
+ self.client.post(
+ "/api/v1.0/orders/",
+ data=data,
+ content_type="application/json",
+ HTTP_AUTHORIZATION=f"Bearer {token}",
+ )
+
+ mocked_round_robin.assert_not_called()
+
+ def test_api_order_create_auto_assign_organization_with_least_orders(self):
+ """
+ Order auto-assignment logic should always return the organization with the least
+ active orders count for the given product course relation.
+ """
+ user = factories.UserFactory()
+ token = self.generate_token_from_user(user)
+
+ organizations = factories.OrganizationFactory.create_batch(2)
+
+ relation = factories.CourseProductRelationFactory(organizations=organizations)
+ billing_address = BillingAddressDictFactory()
+
+ organization_with_least_active_orders, other_organization = organizations
+
+ # Create two draft orders for the first organization
+ factories.OrderFactory.create_batch(
+ 2,
+ organization=organization_with_least_active_orders,
+ product=relation.product,
+ course=relation.course,
+ state=enums.ORDER_STATE_DRAFT,
+ )
+
+ # Create three draft orders for the second organization
+ factories.OrderFactory.create_batch(
+ 3,
+ organization=other_organization,
+ product=relation.product,
+ course=relation.course,
+ state=enums.ORDER_STATE_DRAFT,
+ )
+
+ # Cancelled orders should not be taken into account
+ factories.OrderFactory.create_batch(
+ 4,
+ organization=organization_with_least_active_orders,
+ product=relation.product,
+ course=relation.course,
+ state=enums.ORDER_STATE_CANCELED,
+ )
+
+ # Then create an order without organization
+ data = {
+ "course_code": relation.course.code,
+ "product_id": str(relation.product.id),
+ "has_consent_to_terms": True,
+ "billing_address": billing_address,
+ }
+
+ response = self.client.post(
+ "/api/v1.0/orders/",
+ data=data,
+ content_type="application/json",
+ HTTP_AUTHORIZATION=f"Bearer {token}",
+ )
+
+ order_id = response.json()["id"]
+ order = models.Order.objects.get(id=order_id)
+ self.assertEqual(order.organization, organization_with_least_active_orders)
+
@mock.patch.object(
fields.ThumbnailDetailField,
"to_representation",
@@ -579,7 +722,6 @@ def test_api_order_create_authenticated_has_read_only_fields(self, _mock_thumbna
"product_id": str(product.id),
"id": uuid.uuid4(),
"amount": 0.00,
- "has_consent_to_terms": True,
}
token = self.get_user_token("panoramix")
@@ -590,7 +732,6 @@ def test_api_order_create_authenticated_has_read_only_fields(self, _mock_thumbna
HTTP_AUTHORIZATION=f"Bearer {token}",
)
order = models.Order.objects.get()
- order.submit()
# - Order has been successfully created and read_only_fields
# has been ignored.
self.assertEqual(response.status_code, HTTPStatus.CREATED)
@@ -620,6 +761,7 @@ def test_api_order_create_authenticated_has_read_only_fields(self, _mock_thumbna
"cover": "_this_field_is_mocked",
},
"created_on": order.created_on.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
+ "credit_card_id": None,
"enrollment": None,
"main_invoice_reference": None,
"order_group_id": None,
@@ -650,7 +792,7 @@ def test_api_order_create_authenticated_has_read_only_fields(self, _mock_thumbna
"owner": "panoramix",
"product_id": str(product.id),
"target_enrollments": [],
- "state": "validated",
+ "state": enums.ORDER_STATE_COMPLETED,
"target_courses": [
{
"code": target_course.code,
@@ -719,7 +861,6 @@ def test_api_order_create_authenticated_invalid_product(self):
"course_code": course.code,
"organization_id": str(organization.id),
"product_id": str(product.id),
- "has_consent_to_terms": True,
}
token = self.get_user_token("panoramix")
@@ -768,7 +909,6 @@ def test_api_order_create_authenticated_invalid_organization(self):
"course_code": course.code,
"organization_id": str(organization.id),
"product_id": str(product.id),
- "has_consent_to_terms": True,
}
token = self.get_user_token("panoramix")
@@ -819,7 +959,6 @@ def test_api_order_create_authenticated_missing_product_then_course(self):
response.json(),
{
"product_id": ["This field is required."],
- "has_consent_to_terms": ["This field is required."],
},
)
@@ -830,7 +969,6 @@ def test_api_order_create_authenticated_missing_product_then_course(self):
HTTP_AUTHORIZATION=f"Bearer {token}",
data={
"product_id": str(product.id),
- "has_consent_to_terms": True,
},
)
@@ -842,56 +980,6 @@ def test_api_order_create_authenticated_missing_product_then_course(self):
{"__all__": ["Either the course or the enrollment field is required."]},
)
- def test_api_order_create_authenticated_product_with_contract_require_terms_consent(
- self,
- ):
- """
- The payload must contain has_consent_to_terms sets to True to create an order.
- """
- relation = factories.CourseProductRelationFactory()
- token = self.get_user_token("panoramix")
-
- data = {
- "product_id": str(relation.product.id),
- "course_code": relation.course.code,
- }
-
- # - `has_consent_to_terms` is required
- response = self.client.post(
- "/api/v1.0/orders/",
- content_type="application/json",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- data=data,
- )
- self.assertContains(
- response,
- '{"has_consent_to_terms":["This field is required."]}',
- status_code=HTTPStatus.BAD_REQUEST,
- )
-
- # - `has_consent_to_terms` must be set to True
- data.update({"has_consent_to_terms": False})
- response = self.client.post(
- "/api/v1.0/orders/",
- content_type="application/json",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- data=data,
- )
- self.assertContains(
- response,
- '{"has_consent_to_terms":["You must accept the terms and conditions to proceed."]}',
- status_code=HTTPStatus.BAD_REQUEST,
- )
-
- data.update({"has_consent_to_terms": True})
- response = self.client.post(
- "/api/v1.0/orders/",
- content_type="application/json",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- data=data,
- )
- self.assertEqual(response.status_code, HTTPStatus.CREATED)
-
def test_api_order_create_authenticated_product_course_unicity(self):
"""
If a user tries to create a new order while he has already a not canceled order
@@ -910,7 +998,6 @@ def test_api_order_create_authenticated_product_course_unicity(self):
"product_id": str(product.id),
"course_code": course.code,
"organization_id": str(organization.id),
- "has_consent_to_terms": True,
}
response = self.client.post(
@@ -938,10 +1025,10 @@ def test_api_order_create_authenticated_product_course_unicity(self):
self.assertEqual(response.status_code, HTTPStatus.CREATED)
- def test_api_order_create_authenticated_billing_address_not_required(self):
+ def test_api_order_create_authenticated_billing_address_required(self):
"""
When creating an order related to a fee product, if no billing address is
- given, the order is created as draft.
+ given, the order is not created.
"""
user = factories.UserFactory()
token = self.generate_token_from_user(user)
@@ -953,7 +1040,6 @@ def test_api_order_create_authenticated_billing_address_not_required(self):
"product_id": str(product.id),
"course_code": course.code,
"organization_id": str(organization.id),
- "has_consent_to_terms": True,
}
response = self.client.post(
@@ -963,25 +1049,15 @@ def test_api_order_create_authenticated_billing_address_not_required(self):
HTTP_AUTHORIZATION=f"Bearer {token}",
)
- self.assertEqual(models.Order.objects.count(), 1)
- self.assertEqual(response.status_code, HTTPStatus.CREATED)
- self.assertEqual(response.json()["state"], enums.ORDER_STATE_DRAFT)
- order = models.Order.objects.get()
- self.assertEqual(order.state, enums.ORDER_STATE_DRAFT)
+ self.assertEqual(models.Order.objects.count(), 0)
+ self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)
@mock.patch.object(
fields.ThumbnailDetailField,
"to_representation",
return_value="_this_field_is_mocked",
)
- @mock.patch.object(
- DummyPaymentBackend,
- "create_payment",
- side_effect=DummyPaymentBackend().create_payment,
- )
- def test_api_order_create_authenticated_payment_binding(
- self, mock_create_payment, _mock_thumbnail
- ):
+ def test_api_order_create_authenticated_payment_binding(self, _mock_thumbnail):
"""
Create an order to a fee product and then submitting it should create a
payment and bind payment information into the response.
@@ -999,10 +1075,9 @@ def test_api_order_create_authenticated_payment_binding(
"organization_id": str(organization.id),
"product_id": str(product.id),
"billing_address": billing_address,
- "has_consent_to_terms": True,
}
- with self.assertNumQueries(23):
+ with self.assertNumQueries(61):
response = self.client.post(
"/api/v1.0/orders/",
data=data,
@@ -1030,8 +1105,9 @@ def test_api_order_create_authenticated_payment_binding(
"cover": "_this_field_is_mocked",
},
"created_on": order.created_on.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
+ "credit_card_id": None,
"enrollment": None,
- "main_invoice_reference": None,
+ "main_invoice_reference": order.main_invoice.reference,
"order_group_id": None,
"organization": {
"id": str(order.organization.id),
@@ -1061,7 +1137,7 @@ def test_api_order_create_authenticated_payment_binding(
"product_id": str(product.id),
"total": float(product.price),
"total_currency": settings.DEFAULT_CURRENCY,
- "state": "draft",
+ "state": enums.ORDER_STATE_TO_SAVE_PAYMENT_METHOD,
"target_enrollments": [],
"target_courses": [
{
@@ -1116,181 +1192,6 @@ def test_api_order_create_authenticated_payment_binding(
],
},
)
- with self.assertNumQueries(11):
- response = self.client.patch(
- f"/api/v1.0/orders/{order.id}/submit/",
- data=data,
- content_type="application/json",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
- self.assertDictEqual(
- response.json(),
- {
- "payment_info": {
- "payment_id": f"pay_{order.id}",
- "provider_name": "dummy",
- "url": "https://example.com/api/v1.0/payments/notifications",
- }
- },
- )
- mock_create_payment.assert_called_once()
-
- @mock.patch.object(
- DummyPaymentBackend,
- "create_one_click_payment",
- side_effect=DummyPaymentBackend().create_one_click_payment,
- )
- @mock.patch.object(
- fields.ThumbnailDetailField,
- "to_representation",
- return_value="_this_field_is_mocked",
- )
- def test_api_order_create_authenticated_payment_with_registered_credit_card(
- self,
- _mock_thumbnail,
- mock_create_one_click_payment,
- ):
- """
- Create an order to a fee product should create a payment. If user provides
- a credit card id, a one click payment should be triggered and within response
- payment information should contain `is_paid` property.
- """
- user = factories.UserFactory()
- token = self.generate_token_from_user(user)
- course = factories.CourseFactory()
- product = factories.ProductFactory(courses=[course])
- organization = product.course_relations.first().organizations.first()
- credit_card = CreditCardFactory(owner=user)
- billing_address = BillingAddressDictFactory()
-
- data = {
- "course_code": course.code,
- "organization_id": str(organization.id),
- "product_id": str(product.id),
- "billing_address": billing_address,
- "credit_card_id": str(credit_card.id),
- "has_consent_to_terms": True,
- }
-
- response = self.client.post(
- "/api/v1.0/orders/",
- data=data,
- content_type="application/json",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
- self.assertEqual(response.status_code, HTTPStatus.CREATED)
- self.assertEqual(models.Order.objects.count(), 1)
- order = models.Order.objects.get(product=product, course=course, owner=user)
- organization_address = order.organization.addresses.filter(is_main=True).first()
-
- expected_json = {
- "id": str(order.id),
- "certificate_id": None,
- "contract": None,
- "payment_schedule": None,
- "course": {
- "code": course.code,
- "id": str(course.id),
- "title": course.title,
- "cover": "_this_field_is_mocked",
- },
- "created_on": order.created_on.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
- "enrollment": None,
- "main_invoice_reference": None,
- "order_group_id": None,
- "organization": {
- "id": str(order.organization.id),
- "code": order.organization.code,
- "title": order.organization.title,
- "logo": "_this_field_is_mocked",
- "address": {
- "id": str(organization_address.id),
- "address": organization_address.address,
- "city": organization_address.city,
- "country": organization_address.country,
- "first_name": organization_address.first_name,
- "is_main": organization_address.is_main,
- "last_name": organization_address.last_name,
- "postcode": organization_address.postcode,
- "title": organization_address.title,
- }
- if organization_address
- else None,
- "enterprise_code": order.organization.enterprise_code,
- "activity_category_code": order.organization.activity_category_code,
- "contact_phone": order.organization.contact_phone,
- "contact_email": order.organization.contact_email,
- "dpo_email": order.organization.dpo_email,
- },
- "owner": user.username,
- "product_id": str(product.id),
- "total": float(product.price),
- "total_currency": settings.DEFAULT_CURRENCY,
- "state": "draft",
- "target_enrollments": [],
- "target_courses": [],
- }
- self.assertDictEqual(response.json(), expected_json)
-
- response = self.client.patch(
- f"/api/v1.0/orders/{order.id}/submit/",
- data=data,
- content_type="application/json",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
- mock_create_one_click_payment.assert_called_once()
-
- expected_json = {
- "payment_info": {
- "payment_id": f"pay_{order.id}",
- "provider_name": "dummy",
- "url": "https://example.com/api/v1.0/payments/notifications",
- "is_paid": True,
- },
- }
- self.assertDictEqual(response.json(), expected_json)
-
- @mock.patch.object(DummyPaymentBackend, "create_payment")
- def test_api_order_create_authenticated_payment_failed(self, mock_create_payment):
- """
- If payment creation failed, the order should not be created.
- """
- mock_create_payment.side_effect = CreatePaymentFailed("Unreachable endpoint")
- user = factories.UserFactory()
- token = self.generate_token_from_user(user)
- course = factories.CourseFactory()
- product = factories.ProductFactory(courses=[course])
- organization = product.course_relations.first().organizations.first()
- billing_address = BillingAddressDictFactory()
-
- data = {
- "course_code": course.code,
- "organization_id": str(organization.id),
- "product_id": str(product.id),
- "billing_address": billing_address,
- "has_consent_to_terms": True,
- }
-
- response = self.client.post(
- "/api/v1.0/orders/",
- data=data,
- content_type="application/json",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
-
- order_id = response.json()["id"]
-
- response = self.client.patch(
- f"/api/v1.0/orders/{order_id}/submit/",
- data=data,
- content_type="application/json",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
-
- self.assertEqual(models.Order.objects.exclude(state="draft").count(), 0)
- self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)
-
- self.assertDictEqual(response.json(), {"detail": "Unreachable endpoint"})
def test_api_order_create_authenticated_nb_seats(self):
"""
@@ -1312,7 +1213,7 @@ def test_api_order_create_authenticated_nb_seats(self):
factories.OrderFactory(
product=product,
course=course,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
order_group=order_group,
)
data = {
@@ -1321,7 +1222,6 @@ def test_api_order_create_authenticated_nb_seats(self):
"order_group_id": str(order_group.id),
"product_id": str(product.id),
"billing_address": billing_address,
- "has_consent_to_terms": True,
}
token = self.generate_token_from_user(user)
@@ -1371,11 +1271,10 @@ def test_api_order_create_authenticated_no_seats(self):
"order_group_id": str(order_group.id),
"product_id": str(product.id),
"billing_address": billing_address,
- "has_consent_to_terms": True,
}
token = self.generate_token_from_user(user)
- with self.assertNumQueries(70):
+ with self.assertNumQueries(112):
response = self.client.post(
"/api/v1.0/orders/",
data=data,
@@ -1402,7 +1301,6 @@ def test_api_order_create_authenticated_free_product_no_billing_address(self):
"course_code": course.code,
"organization_id": str(organization.id),
"product_id": str(product.id),
- "has_consent_to_terms": True,
}
response = self.client.post(
"/api/v1.0/orders/",
@@ -1411,35 +1309,29 @@ def test_api_order_create_authenticated_free_product_no_billing_address(self):
HTTP_AUTHORIZATION=f"Bearer {token}",
)
self.assertEqual(response.status_code, HTTPStatus.CREATED)
- self.assertEqual(response.json()["state"], enums.ORDER_STATE_DRAFT)
- order = models.Order.objects.get(id=response.json()["id"])
- response = self.client.patch(
- f"/api/v1.0/orders/{order.id}/submit/",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
- self.assertEqual(response.status_code, HTTPStatus.CREATED)
- order.refresh_from_db()
- self.assertEqual(order.state, enums.ORDER_STATE_VALIDATED)
+ self.assertEqual(response.json()["state"], enums.ORDER_STATE_COMPLETED)
- def test_api_order_create_authenticated_no_billing_address_to_validation(self):
+ def test_api_order_create_authenticated_to_pending(self):
"""
- Create an order on a fee product should be done in 3 steps.
- First create the order in draft state. Then submit the order by
- providing a billing address should pass the order state to `submitted`
- and return payment information. Once the payment has been done, the order
- should be validated.
+ Create an order on a fee product with billing address and credit card.
"""
user = factories.UserFactory()
token = self.generate_token_from_user(user)
course = factories.CourseFactory()
- product = factories.ProductFactory(courses=[course])
+ run = factories.CourseRunFactory(state=CourseState.ONGOING_OPEN)
+ product = factories.ProductFactory(
+ courses=[course], target_courses=[run.course]
+ )
organization = product.course_relations.first().organizations.first()
+ billing_address = BillingAddressDictFactory()
+ credit_card = CreditCardFactory(owner=user)
data = {
"course_code": course.code,
"organization_id": str(organization.id),
"product_id": str(product.id),
- "has_consent_to_terms": True,
+ "billing_address": billing_address,
+ "credit_card_id": str(credit_card.id),
}
response = self.client.post(
@@ -1449,24 +1341,10 @@ def test_api_order_create_authenticated_no_billing_address_to_validation(self):
HTTP_AUTHORIZATION=f"Bearer {token}",
)
self.assertEqual(response.status_code, HTTPStatus.CREATED)
- self.assertEqual(response.json()["state"], enums.ORDER_STATE_DRAFT)
+ self.assertEqual(response.json()["state"], enums.ORDER_STATE_PENDING)
order_id = response.json()["id"]
- billing_address = BillingAddressDictFactory()
- data["billing_address"] = billing_address
- response = self.client.patch(
- f"/api/v1.0/orders/{order_id}/submit/",
- data=data,
- content_type="application/json",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
- self.assertEqual(response.status_code, HTTPStatus.CREATED)
order = models.Order.objects.get(id=order_id)
- self.assertEqual(order.state, enums.ORDER_STATE_SUBMITTED)
-
- InvoiceFactory(order=order)
- order.flow.validate()
- order.refresh_from_db()
- self.assertEqual(order.state, enums.ORDER_STATE_VALIDATED)
+ self.assertEqual(order.state, enums.ORDER_STATE_PENDING)
def test_api_order_create_order_group_required(self):
"""
@@ -1488,7 +1366,6 @@ def test_api_order_create_order_group_required(self):
"organization_id": str(relation.organizations.first().id),
"product_id": str(product.id),
"billing_address": billing_address,
- "has_consent_to_terms": True,
}
token = self.generate_token_from_user(user)
@@ -1532,7 +1409,6 @@ def test_api_order_create_order_group_unrelated(self):
"organization_id": str(organization.id),
"product_id": str(relation.product.id),
"billing_address": billing_address,
- "has_consent_to_terms": True,
}
response = self.client.post(
@@ -1574,14 +1450,15 @@ def test_api_order_create_several_order_groups(self):
product=product,
course=course,
order_group=order_group1,
- state=random.choice(["submitted", "validated"]),
+ state=random.choice(
+ [enums.ORDER_STATE_PENDING, enums.ORDER_STATE_COMPLETED]
+ ),
)
data = {
"course_code": course.code,
"organization_id": str(relation.organizations.first().id),
"product_id": str(product.id),
"billing_address": billing_address,
- "has_consent_to_terms": True,
}
token = self.generate_token_from_user(user)
@@ -1636,7 +1513,6 @@ def test_api_order_create_inactive_order_groups(self):
"organization_id": str(relation.organizations.first().id),
"product_id": str(product.id),
"billing_address": billing_address,
- "has_consent_to_terms": True,
}
token = self.generate_token_from_user(user)
diff --git a/src/backend/joanie/tests/core/api/order/test_lifecycle.py b/src/backend/joanie/tests/core/api/order/test_lifecycle.py
new file mode 100644
index 000000000..66db0fd2d
--- /dev/null
+++ b/src/backend/joanie/tests/core/api/order/test_lifecycle.py
@@ -0,0 +1,83 @@
+"""Tests for the Order lifecycle through API."""
+
+from joanie.core import enums, factories, models
+from joanie.core.models import CourseState
+from joanie.payment.factories import BillingAddressDictFactory, CreditCardFactory
+from joanie.signature.backends import get_signature_backend
+from joanie.tests.base import BaseAPITestCase
+
+
+class OrderLifecycle(BaseAPITestCase):
+ """
+ Test the lifecycle of an order.
+ """
+
+ maxDiff = None
+
+ def test_order_lifecycle(self):
+ """
+ Test the lifecycle of an order.
+ """
+ target_courses = factories.CourseFactory.create_batch(
+ 2,
+ course_runs=factories.CourseRunFactory.create_batch(
+ 2, state=CourseState.ONGOING_OPEN
+ ),
+ )
+ product = factories.ProductFactory(
+ target_courses=target_courses,
+ contract_definition=factories.ContractDefinitionFactory(),
+ )
+ organization = product.course_relations.first().organizations.first()
+
+ user = factories.UserFactory()
+ token = self.generate_token_from_user(user)
+ data = {
+ "course_code": product.courses.first().code,
+ "organization_id": str(organization.id),
+ "product_id": str(product.id),
+ "billing_address": BillingAddressDictFactory(),
+ }
+
+ response = self.client.post(
+ "/api/v1.0/orders/",
+ data=data,
+ content_type="application/json",
+ HTTP_AUTHORIZATION=f"Bearer {token}",
+ )
+
+ order = models.Order.objects.get(id=response.json().get("id"))
+ self.assertEqual(order.state, enums.ORDER_STATE_TO_SIGN)
+
+ self.client.post(
+ f"/api/v1.0/orders/{order.id}/submit_for_signature/",
+ HTTP_AUTHORIZATION=f"Bearer {token}",
+ )
+
+ order.refresh_from_db()
+ self.assertEqual(order.state, enums.ORDER_STATE_SIGNING)
+
+ backend = get_signature_backend()
+ backend.confirm_student_signature(
+ reference=order.contract.signature_backend_reference
+ )
+
+ order.refresh_from_db()
+ self.assertEqual(order.state, enums.ORDER_STATE_TO_SAVE_PAYMENT_METHOD)
+
+ credit_card = CreditCardFactory(owner=user)
+ self.client.post(
+ f"/api/v1.0/orders/{order.id}/payment-method/",
+ data={"credit_card_id": str(credit_card.id)},
+ HTTP_AUTHORIZATION=f"Bearer {token}",
+ )
+
+ order.refresh_from_db()
+ self.assertEqual(order.state, enums.ORDER_STATE_PENDING)
+
+ # simulate payments
+ for installment in order.payment_schedule:
+ order.set_installment_paid(installment["id"])
+
+ order.refresh_from_db()
+ self.assertEqual(order.state, enums.ORDER_STATE_COMPLETED)
diff --git a/src/backend/joanie/tests/core/api/order/test_payment_method.py b/src/backend/joanie/tests/core/api/order/test_payment_method.py
new file mode 100644
index 000000000..13e1ed6b4
--- /dev/null
+++ b/src/backend/joanie/tests/core/api/order/test_payment_method.py
@@ -0,0 +1,147 @@
+"""Tests for the Order payment method API."""
+
+from http import HTTPStatus
+
+from joanie.core import enums, factories
+from joanie.core.models import CourseState
+from joanie.payment.factories import CreditCardFactory, InvoiceFactory
+from joanie.tests.base import BaseAPITestCase
+
+
+class OrderPaymentMethodApiTest(BaseAPITestCase):
+ """Test the API of the Order payment method endpoint."""
+
+ def test_order_payment_method_anoymous(self):
+ """
+ Anonymous users should not be able to set a payment method on an order.
+ """
+ order = factories.OrderFactory(
+ state=enums.ORDER_STATE_TO_SAVE_PAYMENT_METHOD,
+ credit_card=None,
+ )
+ self.assertFalse(order.has_payment_method)
+
+ response = self.client.post(
+ f"/api/v1.0/orders/{order.id}/payment-method/",
+ data={"credit_card_id": "1"},
+ content_type="application/json",
+ )
+
+ self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED)
+ order.refresh_from_db()
+ self.assertEqual(order.state, enums.ORDER_STATE_TO_SAVE_PAYMENT_METHOD)
+ self.assertIsNone(order.credit_card)
+ self.assertFalse(order.has_payment_method)
+
+ def test_order_payment_method_user_is_not_order_owner(self):
+ """
+ Authenticated users should not be able to set a payment method on an order
+ if they are not the owner of the order.
+ """
+ order = factories.OrderFactory(
+ state=enums.ORDER_STATE_TO_SAVE_PAYMENT_METHOD,
+ credit_card=None,
+ )
+ self.assertFalse(order.has_payment_method)
+
+ user = factories.UserFactory()
+ token = self.generate_token_from_user(user)
+ response = self.client.post(
+ f"/api/v1.0/orders/{order.id}/payment-method/",
+ data={"credit_card_id": "1"},
+ content_type="application/json",
+ HTTP_AUTHORIZATION=f"Bearer {token}",
+ )
+
+ self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND)
+ order.refresh_from_db()
+ self.assertEqual(order.state, enums.ORDER_STATE_TO_SAVE_PAYMENT_METHOD)
+ self.assertIsNone(order.credit_card)
+ self.assertFalse(order.has_payment_method)
+
+ def test_order_payment_method_no_credit_card(self):
+ """
+ Authenticated users should not be able to set a payment method on an order
+ if they do not provide a credit card id.
+ """
+ order = factories.OrderFactory(
+ state=enums.ORDER_STATE_TO_SAVE_PAYMENT_METHOD,
+ credit_card=None,
+ )
+ self.assertFalse(order.has_payment_method)
+
+ token = self.generate_token_from_user(order.owner)
+ response = self.client.post(
+ f"/api/v1.0/orders/{order.id}/payment-method/",
+ data={},
+ content_type="application/json",
+ HTTP_AUTHORIZATION=f"Bearer {token}",
+ )
+
+ self.assertContains(
+ response,
+ '{"credit_card_id":"This field is required."}',
+ status_code=HTTPStatus.BAD_REQUEST,
+ )
+ order.refresh_from_db()
+ self.assertEqual(order.state, enums.ORDER_STATE_TO_SAVE_PAYMENT_METHOD)
+ self.assertIsNone(order.credit_card)
+ self.assertFalse(order.has_payment_method)
+
+ def test_order_payment_method_user_is_not_credit_card_owner(self):
+ """
+ Authenticated users should not be able to set a payment method on an order
+ if they are not the owner of the credit card.
+ """
+ order = factories.OrderFactory(
+ state=enums.ORDER_STATE_TO_SAVE_PAYMENT_METHOD,
+ credit_card=None,
+ )
+ self.assertFalse(order.has_payment_method)
+
+ credit_card = CreditCardFactory()
+ token = self.generate_token_from_user(order.owner)
+ response = self.client.post(
+ f"/api/v1.0/orders/{order.id}/payment-method/",
+ data={"credit_card_id": str(credit_card.id)},
+ content_type="application/json",
+ HTTP_AUTHORIZATION=f"Bearer {token}",
+ )
+
+ self.assertContains(
+ response, "Credit card does not exist.", status_code=HTTPStatus.NOT_FOUND
+ )
+ order.refresh_from_db()
+ self.assertEqual(order.state, enums.ORDER_STATE_TO_SAVE_PAYMENT_METHOD)
+ self.assertIsNone(order.credit_card)
+ self.assertFalse(order.has_payment_method)
+
+ def test_order_payment_method_authenticated(self):
+ """
+ Authenticated users should be able to set a payment method on an order
+ by providing a credit card id.
+ """
+ run = factories.CourseRunFactory(state=CourseState.ONGOING_OPEN)
+ order = factories.OrderFactory(
+ product__target_courses=[run.course],
+ state=enums.ORDER_STATE_TO_SAVE_PAYMENT_METHOD,
+ credit_card=None,
+ )
+ InvoiceFactory(order=order)
+
+ self.assertFalse(order.has_payment_method)
+
+ credit_card = CreditCardFactory(owner=order.owner)
+ token = self.generate_token_from_user(order.owner)
+ response = self.client.post(
+ f"/api/v1.0/orders/{order.id}/payment-method/",
+ data={"credit_card_id": str(credit_card.id)},
+ content_type="application/json",
+ HTTP_AUTHORIZATION=f"Bearer {token}",
+ )
+
+ self.assertEqual(response.status_code, HTTPStatus.CREATED)
+ order.refresh_from_db()
+ self.assertEqual(order.state, enums.ORDER_STATE_PENDING)
+ self.assertEqual(order.credit_card, credit_card)
+ self.assertTrue(order.has_payment_method)
diff --git a/src/backend/joanie/tests/core/api/order/test_read_detail.py b/src/backend/joanie/tests/core/api/order/test_read_detail.py
index 05dcaf18a..16a29c2a7 100644
--- a/src/backend/joanie/tests/core/api/order/test_read_detail.py
+++ b/src/backend/joanie/tests/core/api/order/test_read_detail.py
@@ -9,7 +9,7 @@
from django.core.cache import cache
from joanie.core import factories
-from joanie.core.enums import ORDER_STATE_VALIDATED
+from joanie.core.enums import ORDER_STATE_COMPLETED
from joanie.core.models import CourseState
from joanie.core.serializers import fields
from joanie.tests import format_date
@@ -63,7 +63,7 @@ def test_api_order_read_detail_authenticated_owner(self, _mock_thumbnail):
),
student_signed_on=datetime(2023, 9, 20, 8, 0, tzinfo=ZoneInfo("UTC")),
),
- state=ORDER_STATE_VALIDATED,
+ state=ORDER_STATE_COMPLETED,
)
# Generate payment schedule
order.generate_schedule()
@@ -71,7 +71,7 @@ def test_api_order_read_detail_authenticated_owner(self, _mock_thumbnail):
organization_address = order.organization.addresses.filter(is_main=True).first()
token = self.generate_token_from_user(owner)
- with self.assertNumQueries(9):
+ with self.assertNumQueries(10):
response = self.client.get(
f"/api/v1.0/orders/{order.id}/",
HTTP_AUTHORIZATION=f"Bearer {token}",
@@ -103,6 +103,7 @@ def test_api_order_read_detail_authenticated_owner(self, _mock_thumbnail):
if order.payment_schedule
else None,
"created_on": order.created_on.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
+ "credit_card_id": str(order.credit_card.id),
"enrollment": None,
"state": order.state,
"main_invoice_reference": order.main_invoice.reference,
diff --git a/src/backend/joanie/tests/core/api/order/test_read_list.py b/src/backend/joanie/tests/core/api/order/test_read_list.py
index 8a6bada46..31c68017f 100644
--- a/src/backend/joanie/tests/core/api/order/test_read_list.py
+++ b/src/backend/joanie/tests/core/api/order/test_read_list.py
@@ -51,7 +51,7 @@ def test_api_order_read_list_authenticated(self, _mock_thumbnail):
# The owner can see his/her order
token = self.generate_token_from_user(order.owner)
- with self.assertNumQueries(6):
+ with self.assertNumQueries(7):
response = self.client.get(
"/api/v1.0/orders/",
HTTP_AUTHORIZATION=f"Bearer {token}",
@@ -78,6 +78,7 @@ def test_api_order_read_list_authenticated(self, _mock_thumbnail):
"created_on": order.created_on.strftime(
"%Y-%m-%dT%H:%M:%S.%fZ"
),
+ "credit_card_id": str(order.credit_card.id),
"enrollment": None,
"id": str(order.id),
"main_invoice_reference": None,
@@ -151,6 +152,7 @@ def test_api_order_read_list_authenticated(self, _mock_thumbnail):
"created_on": other_order.created_on.strftime(
"%Y-%m-%dT%H:%M:%S.%fZ"
),
+ "credit_card_id": str(other_order.credit_card.id),
"enrollment": None,
"target_enrollments": [],
"main_invoice_reference": None,
@@ -282,6 +284,7 @@ def test_api_order_read_list_filtered_by_product_id(self, _mock_thumbnail):
"created_on": order.created_on.strftime(
"%Y-%m-%dT%H:%M:%S.%fZ"
),
+ "credit_card_id": str(order.credit_card.id),
"enrollment": None,
"target_enrollments": [],
"main_invoice_reference": None,
@@ -395,6 +398,7 @@ def test_api_order_read_list_filtered_by_enrollment_id(self, _mock_thumbnail):
"created_on": order.created_on.strftime(
"%Y-%m-%dT%H:%M:%S.%fZ"
),
+ "credit_card_id": str(order.credit_card.id),
"payment_schedule": None,
"enrollment": {
"course_run": {
@@ -535,7 +539,7 @@ def test_api_order_read_list_filtered_by_course_code(self, _mock_thumbnail):
token = self.generate_token_from_user(user)
# Retrieve user's order related to the first course linked to the product 1
- with self.assertNumQueries(7):
+ with self.assertNumQueries(8):
response = self.client.get(
f"/api/v1.0/orders/?course_code={product_1.courses.first().code}",
HTTP_AUTHORIZATION=f"Bearer {token}",
@@ -563,6 +567,7 @@ def test_api_order_read_list_filtered_by_course_code(self, _mock_thumbnail):
"created_on": order.created_on.strftime(
"%Y-%m-%dT%H:%M:%S.%fZ"
),
+ "credit_card_id": str(order.credit_card.id),
"payment_schedule": None,
"enrollment": None,
"target_enrollments": [],
@@ -657,6 +662,7 @@ def test_api_order_read_list_filtered_by_product_type(self, _mock_thumbnail):
"created_on": order.created_on.strftime(
"%Y-%m-%dT%H:%M:%S.%fZ"
),
+ "credit_card_id": str(order.credit_card.id),
"enrollment": {
"course_run": {
"course": {
@@ -786,7 +792,7 @@ def test_api_order_read_list_filtered_with_multiple_product_type(self):
token = self.generate_token_from_user(user)
# Retrieve user's orders without any filter
- with self.assertNumQueries(146):
+ with self.assertNumQueries(148):
response = self.client.get(
"/api/v1.0/orders/",
HTTP_AUTHORIZATION=f"Bearer {token}",
@@ -797,7 +803,7 @@ def test_api_order_read_list_filtered_with_multiple_product_type(self):
self.assertEqual(content["count"], 3)
# Retrieve user's orders filtered to limit to 2 product types
- with self.assertNumQueries(10):
+ with self.assertNumQueries(11):
response = self.client.get(
(
f"/api/v1.0/orders/?product_type={enums.PRODUCT_TYPE_CERTIFICATE}"
@@ -920,6 +926,7 @@ def test_api_order_read_list_filtered_by_state_draft(self, _mock_thumbnail):
"created_on": order.created_on.strftime(
"%Y-%m-%dT%H:%M:%S.%fZ"
),
+ "credit_card_id": str(order.credit_card.id),
"enrollment": None,
"target_enrollments": [],
"main_invoice_reference": None,
@@ -1007,6 +1014,7 @@ def test_api_order_read_list_filtered_by_state_canceled(self, _mock_thumbnail):
"created_on": order.created_on.strftime(
"%Y-%m-%dT%H:%M:%S.%fZ"
),
+ "credit_card_id": str(order.credit_card.id),
"enrollment": None,
"target_enrollments": [],
"main_invoice_reference": None,
@@ -1059,7 +1067,7 @@ def test_api_order_read_list_filtered_by_state_validated(self, _mock_thumbnail):
# User purchases the product 1 as its price is equal to 0.00€,
# the order is directly validated
order = factories.OrderFactory(
- owner=user, product=product_1, state=enums.ORDER_STATE_VALIDATED
+ owner=user, product=product_1, state=enums.ORDER_STATE_COMPLETED
)
# User purchases the product 2 then cancels it
@@ -1071,7 +1079,7 @@ def test_api_order_read_list_filtered_by_state_validated(self, _mock_thumbnail):
# Retrieve user's order related to the product 1
response = self.client.get(
- "/api/v1.0/orders/?state=validated",
+ "/api/v1.0/orders/?state=completed",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
@@ -1098,6 +1106,7 @@ def test_api_order_read_list_filtered_by_state_validated(self, _mock_thumbnail):
"created_on": order.created_on.strftime(
"%Y-%m-%dT%H:%M:%S.%fZ"
),
+ "credit_card_id": None,
"enrollment": None,
"target_enrollments": [],
"main_invoice_reference": order.main_invoice.reference,
@@ -1148,9 +1157,9 @@ def test_api_order_read_list_filtered_by_multiple_states(self, _mock_thumbnail):
# User purchases products as their price are equal to 0.00€,
# the orders are directly validated
- factories.OrderFactory(owner=user, state=enums.ORDER_STATE_VALIDATED)
+ factories.OrderFactory(owner=user, state=enums.ORDER_STATE_COMPLETED)
factories.OrderFactory(owner=user, state=enums.ORDER_STATE_PENDING)
- factories.OrderFactory(owner=user, state=enums.ORDER_STATE_SUBMITTED)
+ factories.OrderFactory(owner=user, state=enums.ORDER_STATE_PENDING_PAYMENT)
# User purchases a product then cancels it
factories.OrderFactory(owner=user, state=enums.ORDER_STATE_CANCELED)
@@ -1169,7 +1178,7 @@ def test_api_order_read_list_filtered_by_multiple_states(self, _mock_thumbnail):
# Retrieve user's orders filtered to limit to 3 states
response = self.client.get(
- "/api/v1.0/orders/?state=validated&state=submitted&state=pending",
+ "/api/v1.0/orders/?state=completed&state=pending_payment&state=pending",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
@@ -1182,15 +1191,15 @@ def test_api_order_read_list_filtered_by_multiple_states(self, _mock_thumbnail):
self.assertEqual(
order_states,
[
+ enums.ORDER_STATE_COMPLETED,
enums.ORDER_STATE_PENDING,
- enums.ORDER_STATE_SUBMITTED,
- enums.ORDER_STATE_VALIDATED,
+ enums.ORDER_STATE_PENDING_PAYMENT,
],
)
# Retrieve user's orders filtered to exclude 2 states
response = self.client.get(
- "/api/v1.0/orders/?state_exclude=validated&state_exclude=pending",
+ "/api/v1.0/orders/?state_exclude=completed&state_exclude=pending",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
@@ -1204,7 +1213,7 @@ def test_api_order_read_list_filtered_by_multiple_states(self, _mock_thumbnail):
order_states,
[
enums.ORDER_STATE_CANCELED,
- enums.ORDER_STATE_SUBMITTED,
+ enums.ORDER_STATE_PENDING_PAYMENT,
],
)
diff --git a/src/backend/joanie/tests/core/api/order/test_submit.py b/src/backend/joanie/tests/core/api/order/test_submit.py
deleted file mode 100644
index 6a502e22b..000000000
--- a/src/backend/joanie/tests/core/api/order/test_submit.py
+++ /dev/null
@@ -1,377 +0,0 @@
-"""Tests for the Order submit API."""
-
-from http import HTTPStatus
-from unittest import mock
-
-from django.core.cache import cache
-
-from joanie.core import enums, factories
-from joanie.core.api.client import OrderViewSet
-from joanie.payment.factories import BillingAddressDictFactory
-from joanie.tests.base import BaseAPITestCase
-
-
-class OrderSubmitApiTest(BaseAPITestCase):
- """Test the API of the Order submit endpoint."""
-
- maxDiff = None
-
- def _get_fee_order(self, **kwargs):
- """Return a fee order linked to a course."""
- return factories.OrderFactory(**kwargs)
-
- def _get_fee_enrollment_order(self, **kwargs):
- """Return a fee order linked to an enrollment."""
- relation = factories.CourseProductRelationFactory(
- product__type=enums.PRODUCT_TYPE_CERTIFICATE
- )
- enrollment = factories.EnrollmentFactory(
- user=kwargs["owner"], course_run__course=relation.course
- )
-
- return factories.OrderFactory(
- **kwargs,
- course=None,
- enrollment=enrollment,
- product=relation.product,
- )
-
- def _get_free_order(self, **kwargs):
- """Return a free order."""
- product = factories.ProductFactory(price=0.00)
-
- return factories.OrderFactory(**kwargs, product=product)
-
- def setUp(self):
- """Clear cache after each tests"""
- cache.clear()
-
- def test_api_order_submit_anonymous(self):
- """
- Anonymous user cannot submit order
- """
- order = factories.OrderFactory()
- response = self.client.patch(
- f"/api/v1.0/orders/{order.id}/submit/",
- content_type="application/json",
- )
- self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED)
- order.refresh_from_db()
- self.assertEqual(order.state, enums.ORDER_STATE_DRAFT)
-
- def test_api_order_submit_authenticated_unexisting(self):
- """
- User should receive 404 when submitting a non existing order
- """
- user = factories.UserFactory()
- token = self.generate_token_from_user(user)
-
- response = self.client.patch(
- "/api/v1.0/orders/notarealid/submit/",
- content_type="application/json",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
- self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND)
-
- def test_api_order_submit_authenticated_not_owned(self):
- """
- Authenticated user should not be able to submit order they don't own
- """
- user = factories.UserFactory()
- token = self.generate_token_from_user(user)
- order = factories.OrderFactory()
-
- response = self.client.patch(
- f"/api/v1.0/orders/{order.id}/submit/",
- content_type="application/json",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- data={"billing_address": BillingAddressDictFactory()},
- )
-
- order.refresh_from_db()
- self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND)
- self.assertEqual(order.state, enums.ORDER_STATE_DRAFT)
-
- def test_api_order_submit_authenticated_no_billing_address(self):
- """
- User should not be able to submit a fee order without billing address
- """
- user = factories.UserFactory()
- token = self.generate_token_from_user(user)
- order = factories.OrderFactory(owner=user)
-
- response = self.client.patch(
- f"/api/v1.0/orders/{order.id}/submit/",
- content_type="application/json",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
-
- order.refresh_from_db()
- self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)
- self.assertDictEqual(
- response.json(), {"billing_address": ["This field is required."]}
- )
- self.assertEqual(order.state, enums.ORDER_STATE_DRAFT)
-
- def test_api_order_submit_authenticated_success(self):
- """
- User should be able to submit a fee order with a billing address
- or a free order without a billing address
- """
- user = factories.UserFactory()
- token = self.generate_token_from_user(user)
-
- orders = [
- self._get_free_order(owner=user),
- self._get_fee_order(owner=user),
- self._get_fee_enrollment_order(owner=user),
- ]
-
- for order in orders:
- # Submitting the fee order
- response = self.client.patch(
- f"/api/v1.0/orders/{order.id}/submit/",
- content_type="application/json",
- data={"billing_address": BillingAddressDictFactory()},
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
-
- order.refresh_from_db()
- self.assertEqual(response.status_code, HTTPStatus.CREATED)
- # Order should have been automatically validated if it is free
- # Otherwise it should have been submitted
- self.assertEqual(
- order.state,
- enums.ORDER_STATE_SUBMITTED
- if order.total > 0
- else enums.ORDER_STATE_VALIDATED,
- )
-
- def test_api_order_submit_should_auto_assign_organization(self):
- """
- On submit request, if the related order has no organization linked yet, the one
- implied in the course product organization with the least order should be
- assigned.
- """
- user = factories.UserFactory()
- token = self.generate_token_from_user(user)
-
- orders = [
- self._get_free_order(owner=user, organization=None),
- self._get_fee_order(owner=user, organization=None),
- self._get_fee_enrollment_order(owner=user, organization=None),
- ]
-
- for order in orders:
- # Order should have no organization set yet
- self.assertIsNone(order.organization)
-
- response = self.client.patch(
- f"/api/v1.0/orders/{order.id}/submit/",
- content_type="application/json",
- data={"billing_address": BillingAddressDictFactory()},
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
-
- order.refresh_from_db()
- self.assertEqual(response.status_code, HTTPStatus.CREATED)
- # Now order should have an organization set
- self.assertIsNotNone(order.organization)
-
- @mock.patch.object(
- OrderViewSet, "_get_organization_with_least_active_orders", return_value=None
- )
- def test_api_order_submit_should_auto_assign_organization_if_needed(
- self, mocked_round_robin
- ):
- """
- Order should have organization auto assigned only on submit if it has
- not already one linked.
- """
- user = factories.UserFactory()
- token = self.generate_token_from_user(user)
-
- # Auto assignment should have been triggered if order has no organization linked
- order = factories.OrderFactory(owner=user, organization=None)
- self.client.patch(
- f"/api/v1.0/orders/{order.id}/submit/",
- content_type="application/json",
- data={"billing_address": BillingAddressDictFactory()},
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
-
- mocked_round_robin.assert_called_once()
-
- mocked_round_robin.reset_mock()
-
- # Auto assignment should not have been
- # triggered if order already has an organization linked
- order = factories.OrderFactory(owner=user)
- self.client.patch(
- f"/api/v1.0/orders/{order.id}/submit/",
- content_type="application/json",
- data={"billing_address": BillingAddressDictFactory()},
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
-
- mocked_round_robin.assert_not_called()
-
- @mock.patch.object(
- OrderViewSet, "_get_organization_with_least_active_orders", return_value=None
- )
- def test_api_order_submit_should_auto_assign_organization_if_pending(
- self, mocked_round_robin
- ):
- """
- Order should have organization auto assigned on submit if its state is
- pending
- """
- user = factories.UserFactory()
- token = self.generate_token_from_user(user)
-
- # Auto assignment should have been triggered if order has no organization linked
- order = factories.OrderFactory(owner=user, state=enums.ORDER_STATE_PENDING)
- self.client.patch(
- f"/api/v1.0/orders/{order.id}/submit/",
- content_type="application/json",
- data={"billing_address": BillingAddressDictFactory()},
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
-
- mocked_round_robin.assert_called_once()
-
- def test_api_order_submit_auto_assign_organization_with_least_orders(self):
- """
- Order auto-assignment logic should always return the organization with the least
- active orders count for the given product course relation.
- """
- user = factories.UserFactory()
- token = self.generate_token_from_user(user)
-
- organization, expected_organization = (
- factories.OrganizationFactory.create_batch(2)
- )
-
- relation = factories.CourseProductRelationFactory(
- organizations=[organization, expected_organization]
- )
-
- # Create 3 orders for the first organization (1 draft, 1 pending, 1 canceled)
- factories.OrderFactory(
- organization=organization,
- product=relation.product,
- course=relation.course,
- state=enums.ORDER_STATE_DRAFT,
- )
- factories.OrderFactory(
- organization=organization,
- product=relation.product,
- course=relation.course,
- state=enums.ORDER_STATE_PENDING,
- )
- factories.OrderFactory(
- organization=organization,
- product=relation.product,
- course=relation.course,
- state=enums.ORDER_STATE_CANCELED,
- )
-
- # 2 ignored orders for the second organization (1 pending, 1 canceled)
- factories.OrderFactory(
- organization=expected_organization,
- product=relation.product,
- course=relation.course,
- state=enums.ORDER_STATE_PENDING,
- )
- factories.OrderFactory(
- organization=expected_organization,
- product=relation.product,
- course=relation.course,
- state=enums.ORDER_STATE_CANCELED,
- )
-
- # Then create an order without organization
- order = factories.OrderFactory(
- owner=user,
- product=relation.product,
- course=relation.course,
- organization=None,
- )
-
- # Submit it should auto assign organization with least active orders
- self.client.patch(
- f"/api/v1.0/orders/{order.id}/submit/",
- content_type="application/json",
- data={"billing_address": BillingAddressDictFactory()},
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
-
- order.refresh_from_db()
- self.assertEqual(order.organization, expected_organization)
-
- def test_api_order_submit_get_organization_with_least_active_orders_prefer_author(
- self,
- ):
- """
- In case of order count equality, the method _get_organization_with_least_orders should
- return first organization which is also an author of the course.
- """
- user = factories.UserFactory()
- token = self.generate_token_from_user(user)
-
- organization, expected_organization = (
- factories.OrganizationFactory.create_batch(2)
- )
-
- relation = factories.CourseProductRelationFactory(
- organizations=[organization, expected_organization]
- )
-
- relation.course.organizations.set([expected_organization])
-
- # Create 3 orders for the first organization (1 draft, 1 pending, 1 canceled)
- factories.OrderFactory(
- organization=organization,
- product=relation.product,
- course=relation.course,
- state=enums.ORDER_STATE_PENDING,
- )
- factories.OrderFactory(
- organization=organization,
- product=relation.product,
- course=relation.course,
- state=enums.ORDER_STATE_CANCELED,
- )
-
- # 2 ignored orders for the second organization (1 pending, 1 canceled)
- factories.OrderFactory(
- organization=expected_organization,
- product=relation.product,
- course=relation.course,
- state=enums.ORDER_STATE_PENDING,
- )
- factories.OrderFactory(
- organization=expected_organization,
- product=relation.product,
- course=relation.course,
- state=enums.ORDER_STATE_CANCELED,
- )
-
- # Then create an order without organization
- order = factories.OrderFactory(
- owner=user,
- product=relation.product,
- course=relation.course,
- organization=None,
- )
-
- self.client.patch(
- f"/api/v1.0/orders/{order.id}/submit/",
- content_type="application/json",
- data={"billing_address": BillingAddressDictFactory()},
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
-
- order.refresh_from_db()
-
- self.assertEqual(order.organization, expected_organization)
diff --git a/src/backend/joanie/tests/core/api/order/test_submit_for_signature.py b/src/backend/joanie/tests/core/api/order/test_submit_for_signature.py
index 4347767ed..a9f6a8e66 100644
--- a/src/backend/joanie/tests/core/api/order/test_submit_for_signature.py
+++ b/src/backend/joanie/tests/core/api/order/test_submit_for_signature.py
@@ -1,17 +1,17 @@
"""Tests for the Order submit for signature API."""
import json
-import random
from datetime import timedelta
from http import HTTPStatus
from django.core.cache import cache
from django.test.utils import override_settings
-from django.urls import reverse
from django.utils import timezone as django_timezone
from joanie.core import enums, factories
from joanie.core.models import CourseState
+from joanie.payment.factories import BillingAddressDictFactory
+from joanie.signature.backends import get_signature_backend
from joanie.tests.base import BaseAPITestCase
@@ -34,7 +34,7 @@ def test_api_order_submit_for_signature_anonymous(self):
factories.ContractFactory(order=order)
response = self.client.post(
- reverse("orders-submit-for-signature", kwargs={"pk": order.id}),
+ f"/api/v1.0/orders/{order.id}/submit_for_signature/",
HTTP_AUTHORIZATION="Bearer fake",
)
@@ -58,13 +58,13 @@ def test_api_order_submit_for_signature_user_is_not_owner_of_the_order_to_be_sub
factories.UserAddressFactory(owner=owner)
order = factories.OrderFactory(
owner=owner,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
product=factories.ProductFactory(),
)
token = self.get_user_token(not_owner_user.username)
response = self.client.post(
- reverse("orders-submit-for-signature", kwargs={"pk": order.id}),
+ f"/api/v1.0/orders/{order.id}/submit_for_signature/",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
@@ -73,42 +73,41 @@ def test_api_order_submit_for_signature_user_is_not_owner_of_the_order_to_be_sub
content = response.json()
self.assertEqual(content["detail"], "No Order matches the given query.")
- def test_api_order_submit_for_signature_authenticated_but_order_is_not_validate(
+ def test_api_order_submit_for_signature_authenticated_but_order_is_not_to_sign(
self,
):
"""
- Authenticated users should not be able to submit for signature an order that is
- not state equal to 'validated'.
+ Authenticated users should only be able to submit for signature an order that is
+ in the state 'to sign'.
+ If the order is in another state, it should raise an error.
"""
- user = factories.UserFactory(
- email="student_do@example.fr", first_name="John Doe", last_name=""
- )
+ user = factories.UserFactory()
factories.UserAddressFactory(owner=user)
- order = factories.OrderFactory(
- owner=user,
- state=random.choice(
- [
- enums.ORDER_STATE_CANCELED,
- enums.ORDER_STATE_PENDING,
- enums.ORDER_STATE_SUBMITTED,
- enums.ORDER_STATE_DRAFT,
- ]
- ),
- product__contract_definition=factories.ContractDefinitionFactory(),
- )
- token = self.get_user_token(user.username)
-
- response = self.client.post(
- reverse("orders-submit-for-signature", kwargs={"pk": order.id}),
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
-
- self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)
-
- content = response.json()
- self.assertEqual(
- content[0], "Cannot submit an order that is not yet validated."
- )
+ for state, _ in enums.ORDER_STATE_CHOICES:
+ with self.subTest(state=state):
+ order = factories.OrderGeneratorFactory(owner=user, state=state)
+ token = self.get_user_token(user.username)
+
+ response = self.client.post(
+ f"/api/v1.0/orders/{order.id}/submit_for_signature/",
+ HTTP_AUTHORIZATION=f"Bearer {token}",
+ )
+ content = response.json()
+
+ if state in [enums.ORDER_STATE_TO_SIGN, enums.ORDER_STATE_SIGNING]:
+ self.assertEqual(response.status_code, HTTPStatus.OK)
+ self.assertIsNotNone(content.get("invitation_link"))
+ elif state in [enums.ORDER_STATE_DRAFT, enums.ORDER_STATE_ASSIGNED]:
+ self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)
+ self.assertEqual(
+ content[0],
+ "No contract definition attached to the contract's product.",
+ )
+ else:
+ self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)
+ self.assertEqual(
+ content[0], "Cannot submit an order that is not to sign."
+ )
def test_api_order_submit_for_signature_order_without_product_contract_definition(
self,
@@ -123,13 +122,13 @@ def test_api_order_submit_for_signature_order_without_product_contract_definitio
factories.UserAddressFactory(owner=user)
order = factories.OrderFactory(
owner=user,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
product=factories.ProductFactory(contract_definition=None),
)
token = self.get_user_token(user.username)
response = self.client.post(
- reverse("orders-submit-for-signature", kwargs={"pk": order.id}),
+ f"/api/v1.0/orders/{order.id}/submit_for_signature/",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
@@ -156,25 +155,24 @@ def test_api_order_submit_for_signature_authenticated(self):
)
order = factories.OrderFactory(
owner=user,
- state=enums.ORDER_STATE_VALIDATED,
product__contract_definition=factories.ContractDefinitionFactory(),
product__target_courses=target_courses,
)
+ order.init_flow(billing_address=BillingAddressDictFactory())
token = self.get_user_token(user.username)
- expected_substring_invite_url = (
- "https://dummysignaturebackend.fr/?requestToken="
- )
+ expected_substring_invite_url = "https://dummysignaturebackend.fr/?reference="
response = self.client.post(
- reverse("orders-submit-for-signature", kwargs={"pk": order.id}),
+ f"/api/v1.0/orders/{order.id}/submit_for_signature/",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
+ order.refresh_from_db()
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertIsNotNone(order.contract)
self.assertIsNotNone(order.contract.context)
self.assertIsNotNone(order.contract.definition_checksum)
- self.assertIsNotNone(order.contract.student_signed_on)
+ self.assertIsNone(order.contract.student_signed_on)
self.assertIsNotNone(order.contract.submitted_for_signature_on)
content = response.content.decode("utf-8")
@@ -183,6 +181,13 @@ def test_api_order_submit_for_signature_authenticated(self):
self.assertIn(expected_substring_invite_url, invitation_url)
+ backend = get_signature_backend()
+ backend.confirm_student_signature(
+ reference=order.contract.signature_backend_reference
+ )
+ order.refresh_from_db()
+ self.assertIsNotNone(order.contract.student_signed_on)
+
@override_settings(
JOANIE_SIGNATURE_VALIDITY_PERIOD_IN_SECONDS=60 * 60 * 24 * 15,
)
@@ -197,29 +202,20 @@ def test_api_order_submit_for_signature_contract_be_resubmitted_with_validity_pe
'definition_checksum', 'signature_backend_reference' and 'submitted_for_signature_on'.
In return we must have in the response the invitation link to sign the file.
"""
- user = factories.UserFactory(
- email="student_do@example.fr", first_name="John Doe", last_name=""
- )
- order = factories.OrderFactory(
- owner=user,
- state=enums.ORDER_STATE_VALIDATED,
- product__contract_definition=factories.ContractDefinitionFactory(),
- )
- token = self.get_user_token(user.username)
- contract = factories.ContractFactory(
- order=order,
- definition=order.product.contract_definition,
- signature_backend_reference="wfl_fake_dummy_id_will_be_updated",
- definition_checksum="fake_test_file_hash_will_be_updated",
- context="content",
- submitted_for_signature_on=django_timezone.now() - timedelta(days=16),
- )
- expected_substring_invite_url = (
- "https://dummysignaturebackend.fr/?requestToken="
+ order = factories.OrderGeneratorFactory(
+ state=enums.ORDER_STATE_SIGNING,
+ contract__submitted_for_signature_on=django_timezone.now()
+ - timedelta(days=16),
+ contract__signature_backend_reference="wfl_fake_dummy_id_will_be_updated",
+ contract__definition_checksum="fake_test_file_hash_will_be_updated",
+ contract__context="content",
)
+ contract = order.contract
+ token = self.get_user_token(order.owner.username)
+ expected_substring_invite_url = "https://dummysignaturebackend.fr/?reference="
response = self.client.post(
- reverse("orders-submit-for-signature", kwargs={"pk": order.id}),
+ f"/api/v1.0/orders/{order.id}/submit_for_signature/",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
@@ -247,30 +243,21 @@ def test_api_order_submit_for_signature_contract_context_has_changed_and_still_v
after synchronizing with the signature provider. We get the invitation link in the
response in return.
"""
- user = factories.UserFactory(
- email="student_do@example.fr", first_name="John Doe", last_name=""
- )
- order = factories.OrderFactory(
- owner=user,
- state=enums.ORDER_STATE_VALIDATED,
- product__contract_definition=factories.ContractDefinitionFactory(),
- )
- token = self.get_user_token(user.username)
- contract = factories.ContractFactory(
- order=order,
- definition=order.product.contract_definition,
- signature_backend_reference="wfl_fake_dummy_id",
- definition_checksum="fake_test_file_hash",
- context="content",
- submitted_for_signature_on=django_timezone.now() - timedelta(days=2),
- )
- contract.definition.body = "a new content"
- expected_substring_invite_url = (
- "https://dummysignaturebackend.fr/?requestToken="
+ order = factories.OrderGeneratorFactory(
+ state=enums.ORDER_STATE_SIGNING,
+ contract__submitted_for_signature_on=django_timezone.now()
+ - timedelta(days=2),
+ contract__signature_backend_reference="wfl_fake_dummy_id",
+ contract__definition_checksum="fake_test_file_hash",
+ contract__context="content",
)
+ contract = order.contract
+ token = self.get_user_token(order.owner.username)
+ order.contract.definition.body = "a new content"
+ expected_substring_invite_url = "https://dummysignaturebackend.fr/?reference="
response = self.client.post(
- reverse("orders-submit-for-signature", kwargs={"pk": order.id}),
+ f"/api/v1.0/orders/{order.id}/submit_for_signature/",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
diff --git a/src/backend/joanie/tests/core/api/order/test_submit_installment_payment.py b/src/backend/joanie/tests/core/api/order/test_submit_installment_payment.py
index 3855c7624..d2d87729f 100644
--- a/src/backend/joanie/tests/core/api/order/test_submit_installment_payment.py
+++ b/src/backend/joanie/tests/core/api/order/test_submit_installment_payment.py
@@ -1,10 +1,13 @@
"""Tests for the Order to submit installment payment API endpoint."""
import uuid
+from datetime import date
from decimal import Decimal as D
from http import HTTPStatus
from unittest import mock
+from stockholm import Money
+
from joanie.core.enums import (
ORDER_STATE_CANCELED,
ORDER_STATE_COMPLETED,
@@ -13,8 +16,6 @@
ORDER_STATE_NO_PAYMENT,
ORDER_STATE_PENDING,
ORDER_STATE_PENDING_PAYMENT,
- ORDER_STATE_SUBMITTED,
- ORDER_STATE_VALIDATED,
PAYMENT_STATE_PAID,
PAYMENT_STATE_PENDING,
PAYMENT_STATE_REFUSED,
@@ -138,29 +139,6 @@ def test_api_order_submit_installment_payment_order_in_draft_state(
{"detail": "The order is not in failed payment state."},
)
- def test_api_order_submit_installment_payment_order_is_in_submitted_state(self):
- """
- Authenticated user should not be able to pay for a failed installment payment
- if its order is in state 'submitted'.
- """
- user = UserFactory()
- token = self.generate_token_from_user(user)
- payload = {"credit_card_id": uuid.uuid4()}
- order_submitted = OrderFactory(owner=user, state=ORDER_STATE_SUBMITTED)
-
- response = self.client.post(
- f"/api/v1.0/orders/{order_submitted.id}/submit-installment-payment/",
- data=payload,
- content_type="application/json",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
-
- self.assertEqual(response.status_code, HTTPStatus.UNPROCESSABLE_ENTITY)
- self.assertEqual(
- response.json(),
- {"detail": "The order is not in failed payment state."},
- )
-
def test_api_order_submit_installment_payment_order_is_in_pending_state(self):
"""
Authenticated user should not be able to pay for a failed installment payment
@@ -207,29 +185,6 @@ def test_api_order_submit_installment_payment_order_is_in_cancelled_state(self):
{"detail": "The order is not in failed payment state."},
)
- def test_api_order_submit_installment_payment_order_is_in_validated_state(self):
- """
- Authenticated user should not be able to pay for a failed installment payment
- if its order is in state 'validated'.
- """
- user = UserFactory()
- token = self.generate_token_from_user(user)
- payload = {"credit_card_id": uuid.uuid4()}
- order_validated = OrderFactory(owner=user, state=ORDER_STATE_VALIDATED)
-
- response = self.client.post(
- f"/api/v1.0/orders/{order_validated.id}/submit-installment-payment/",
- data=payload,
- content_type="application/json",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
-
- self.assertEqual(response.status_code, HTTPStatus.UNPROCESSABLE_ENTITY)
- self.assertEqual(
- response.json(),
- {"detail": "The order is not in failed payment state."},
- )
-
def test_api_order_submit_installment_payment_order_is_in_pending_payment_state(
self,
):
@@ -400,8 +355,8 @@ def test_api_order_submit_installment_payment_without_passing_credit_credit_card
billing_address=order.main_invoice.recipient_address,
installment={
"id": "d9356dd7-19a6-4695-b18e-ad93af41424a",
- "amount": "300.00",
- "due_date": "2024-02-17",
+ "amount": Money("300.00"),
+ "due_date": date(2024, 2, 17),
"state": PAYMENT_STATE_REFUSED,
},
)
@@ -480,8 +435,8 @@ def test_api_order_submit_installment_payment_with_credit_card_id_payload(
credit_card_token=credit_card.token,
installment={
"id": "9fcff723-7be4-4b77-87c6-2865e000f879",
- "amount": "300.00",
- "due_date": "2024-03-17",
+ "amount": Money("300.00"),
+ "due_date": date(2024, 3, 17),
"state": PAYMENT_STATE_REFUSED,
},
)
@@ -609,8 +564,8 @@ def test_api_order_submit_installment_payment_order_no_payment_state_with_credit
credit_card_token=credit_card.token,
installment={
"id": "1932fbc5-d971-48aa-8fee-6d637c3154a5",
- "amount": "200.00",
- "due_date": "2024-01-17",
+ "amount": Money("200.00"),
+ "due_date": date(2024, 1, 17),
"state": PAYMENT_STATE_REFUSED,
},
)
@@ -676,8 +631,8 @@ def test_api_order_submit_installment_payment_order_no_payment_state_without_cre
billing_address=order.main_invoice.recipient_address,
installment={
"id": "1932fbc5-d971-48aa-8fee-6d637c3154a5",
- "amount": "200.00",
- "due_date": "2024-01-17",
+ "amount": Money("200.00"),
+ "due_date": date(2024, 1, 17),
"state": PAYMENT_STATE_REFUSED,
},
)
@@ -744,8 +699,8 @@ def test_api_order_submit_installment_payment_without_credit_card_data_raise_exc
billing_address=order.main_invoice.recipient_address,
installment={
"id": "9fcff723-7be4-4b77-87c6-2865e000f879",
- "amount": "300.00",
- "due_date": "2024-03-17",
+ "amount": Money("300.00"),
+ "due_date": date(2024, 3, 17),
"state": PAYMENT_STATE_REFUSED,
},
)
@@ -822,8 +777,8 @@ def test_api_order_submit_installment_payment_with_credit_card_data_raises_excep
credit_card_token=credit_card.token,
installment={
"id": "168d7e8c-a1a9-4d70-9667-853bf79e502c",
- "amount": "199.99",
- "due_date": "2024-04-17",
+ "amount": Money("199.99"),
+ "due_date": date(2024, 4, 17),
"state": PAYMENT_STATE_REFUSED,
},
)
diff --git a/src/backend/joanie/tests/core/api/order/test_update.py b/src/backend/joanie/tests/core/api/order/test_update.py
index abce483eb..51ba5ef4a 100644
--- a/src/backend/joanie/tests/core/api/order/test_update.py
+++ b/src/backend/joanie/tests/core/api/order/test_update.py
@@ -57,6 +57,7 @@ def _check_api_order_update_detail(self, order, user, error_code):
"contract",
"course",
"created_on",
+ "credit_card_id",
"enrollment",
"id",
"main_invoice_reference",
@@ -143,29 +144,17 @@ def test_api_order_update_detail_authenticated_owned(self):
owner = factories.UserFactory()
*target_courses, _other_course = factories.CourseFactory.create_batch(3)
product = factories.ProductFactory(target_courses=target_courses)
- order = factories.OrderFactory(
- owner=owner, product=product, state=enums.ORDER_STATE_SUBMITTED
- )
- self._check_api_order_update_detail(order, owner, HTTPStatus.METHOD_NOT_ALLOWED)
- models.Order.objects.all().delete()
- order = factories.OrderFactory(
- owner=owner, product=product, state=enums.ORDER_STATE_VALIDATED
- )
- self._check_api_order_update_detail(order, owner, HTTPStatus.METHOD_NOT_ALLOWED)
- Transaction.objects.all().delete()
- Invoice.objects.all().delete()
- models.Order.objects.all().delete()
- order = factories.OrderFactory(
- owner=owner, product=product, state=enums.ORDER_STATE_PENDING
- )
- self._check_api_order_update_detail(order, owner, HTTPStatus.METHOD_NOT_ALLOWED)
- models.Order.objects.all().delete()
- order = factories.OrderFactory(
- owner=owner, product=product, state=enums.ORDER_STATE_CANCELED
- )
- self._check_api_order_update_detail(order, owner, HTTPStatus.METHOD_NOT_ALLOWED)
- models.Order.objects.all().delete()
- order = factories.OrderFactory(
- owner=owner, product=product, state=enums.ORDER_STATE_DRAFT
- )
- self._check_api_order_update_detail(order, owner, HTTPStatus.METHOD_NOT_ALLOWED)
+
+ for state, _ in enums.ORDER_STATE_CHOICES:
+ with self.subTest(state=state):
+ order = factories.OrderFactory(
+ owner=owner, product=product, state=state
+ )
+
+ self._check_api_order_update_detail(
+ order, owner, HTTPStatus.METHOD_NOT_ALLOWED
+ )
+
+ Transaction.objects.all().delete()
+ Invoice.objects.all().delete()
+ models.Order.objects.all().delete()
diff --git a/src/backend/joanie/tests/core/api/order/test_validate.py b/src/backend/joanie/tests/core/api/order/test_validate.py
deleted file mode 100644
index b41fb83ed..000000000
--- a/src/backend/joanie/tests/core/api/order/test_validate.py
+++ /dev/null
@@ -1,79 +0,0 @@
-"""Tests for the Order validate API."""
-
-from http import HTTPStatus
-
-from django.core.cache import cache
-
-from joanie.core import enums, factories
-from joanie.payment.factories import BillingAddressDictFactory, InvoiceFactory
-from joanie.tests.base import BaseAPITestCase
-
-
-class OrderValidateApiTest(BaseAPITestCase):
- """Test the API of the Order validate endpoint."""
-
- maxDiff = None
-
- def setUp(self):
- """Clear cache after each tests"""
- cache.clear()
-
- def test_api_order_validate_anonymous(self):
- """
- Anonymous user should not be able to validate an order
- """
- order = factories.OrderFactory()
- order.submit(billing_address=BillingAddressDictFactory())
- response = self.client.put(
- f"/api/v1.0/orders/{order.id}/validate/",
- )
- self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED)
- order.refresh_from_db()
- self.assertEqual(order.state, enums.ORDER_STATE_SUBMITTED)
-
- def test_api_order_validate_authenticated_unexisting(self):
- """
- User should receive 404 when validating a non existing order
- """
- user = factories.UserFactory()
- token = self.generate_token_from_user(user)
-
- response = self.client.put(
- "/api/v1.0/orders/notarealid/validate/",
- content_type="application/json",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
- self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND)
-
- def test_api_order_validate_authenticated_not_owned(self):
- """
- Authenticated user should not be able to validate order they don't own
- """
- user = factories.UserFactory()
- token = self.generate_token_from_user(user)
- order = factories.OrderFactory()
- order.submit(billing_address=BillingAddressDictFactory())
- response = self.client.put(
- f"/api/v1.0/orders/{order.id}/validate/",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
- order.refresh_from_db()
- self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND)
- self.assertEqual(order.state, enums.ORDER_STATE_SUBMITTED)
-
- def test_api_order_validate_owned(self):
- """
- User should be able to validate order they own
- """
- user = factories.UserFactory()
- token = self.generate_token_from_user(user)
- order = factories.OrderFactory(
- owner=user, state=enums.ORDER_STATE_SUBMITTED, main_invoice=InvoiceFactory()
- )
- response = self.client.put(
- f"/api/v1.0/orders/{order.id}/validate/",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
- order.refresh_from_db()
- self.assertEqual(response.status_code, HTTPStatus.OK)
- self.assertEqual(order.state, enums.ORDER_STATE_VALIDATED)
diff --git a/src/backend/joanie/tests/core/api/organizations/test_api_organizations_contract.py b/src/backend/joanie/tests/core/api/organizations/test_api_organizations_contract.py
index 2a1545f64..6f61d271a 100644
--- a/src/backend/joanie/tests/core/api/organizations/test_api_organizations_contract.py
+++ b/src/backend/joanie/tests/core/api/organizations/test_api_organizations_contract.py
@@ -120,7 +120,7 @@ def test_api_organizations_contracts_list_with_accesses(self, _):
self.assertEqual(response.status_code, HTTPStatus.OK)
contracts = models.Contract.objects.filter(
order__organization=organizations[0],
- order__state=enums.ORDER_STATE_VALIDATED,
+ order__state=enums.ORDER_STATE_COMPLETED,
)
expected_contracts = sorted(contracts, key=lambda x: x.created_on, reverse=True)
assert response.json() == {
diff --git a/src/backend/joanie/tests/core/api/organizations/test_contracts_signature_link.py b/src/backend/joanie/tests/core/api/organizations/test_contracts_signature_link.py
index 57c5a4637..852441876 100644
--- a/src/backend/joanie/tests/core/api/organizations/test_contracts_signature_link.py
+++ b/src/backend/joanie/tests/core/api/organizations/test_contracts_signature_link.py
@@ -9,6 +9,7 @@
from joanie.core import enums, factories
from joanie.core.models import OrganizationAccess
+from joanie.payment.factories import BillingAddressDictFactory
from joanie.tests.base import BaseAPITestCase
@@ -27,8 +28,14 @@ def test_api_organization_contracts_signature_link_without_owner(self):
is_superuser=random.choice([True, False]),
)
order = factories.OrderFactory(
- state=enums.ORDER_STATE_VALIDATED,
product__contract_definition=factories.ContractDefinitionFactory(),
+ payment_schedule=[
+ {
+ "amount": "200.00",
+ "due_date": "2024-01-17",
+ "state": enums.PAYMENT_STATE_PAID,
+ }
+ ],
)
organization_roles_not_owner = [
role[0]
@@ -41,10 +48,12 @@ def test_api_organization_contracts_signature_link_without_owner(self):
role=random.choice(organization_roles_not_owner),
)
- order.submit_for_signature(order.owner)
- order.contract.submitted_for_signature_on = timezone.now()
- order.contract.student_signed_on = timezone.now()
- order.contract.save()
+ factories.ContractFactory(
+ order=order,
+ student_signed_on=timezone.now(),
+ submitted_for_signature_on=timezone.now(),
+ )
+ order.init_flow(billing_address=BillingAddressDictFactory())
token = self.generate_token_from_user(user)
response = self.client.get(
@@ -63,16 +72,24 @@ def test_api_organization_contracts_signature_link_success(self):
Authenticated users with the owner role should be able to sign contracts in bulk.
"""
order = factories.OrderFactory(
- state=enums.ORDER_STATE_VALIDATED,
product__contract_definition=factories.ContractDefinitionFactory(),
+ payment_schedule=[
+ {
+ "amount": "200.00",
+ "due_date": "2024-01-17",
+ "state": enums.PAYMENT_STATE_PAID,
+ }
+ ],
)
access = factories.UserOrganizationAccessFactory(
organization=order.organization, role="owner"
)
- order.submit_for_signature(order.owner)
- order.contract.submitted_for_signature_on = timezone.now()
- order.contract.student_signed_on = timezone.now()
- order.contract.save()
+ factories.ContractFactory(
+ order=order,
+ student_signed_on=timezone.now(),
+ submitted_for_signature_on=timezone.now(),
+ )
+ order.init_flow(billing_address=BillingAddressDictFactory())
token = self.generate_token_from_user(access.user)
response = self.client.get(
@@ -83,7 +100,7 @@ def test_api_organization_contracts_signature_link_success(self):
self.assertEqual(response.status_code, HTTPStatus.OK)
content = response.json()
self.assertIn(
- "https://dummysignaturebackend.fr/?requestToken=",
+ "https://dummysignaturebackend.fr/?reference=",
content["invitation_link"],
)
self.assertCountEqual(
@@ -96,7 +113,6 @@ def test_api_organization_contracts_signature_link_specified_ids(self):
When passing a list of contract ids,
only the contracts with these ids should be signed.
"""
-
organization = factories.OrganizationFactory()
relation = factories.CourseProductRelationFactory(
organizations=[organization],
@@ -107,18 +123,26 @@ def test_api_organization_contracts_signature_link_specified_ids(self):
product=relation.product,
course=relation.course,
organization=organization,
- state=enums.ORDER_STATE_VALIDATED,
product__contract_definition=factories.ContractDefinitionFactory(),
+ payment_schedule=[
+ {
+ "amount": "200.00",
+ "due_date": "2024-01-17",
+ "state": enums.PAYMENT_STATE_PAID,
+ }
+ ],
)
access = factories.UserOrganizationAccessFactory(
organization=organization, role="owner"
)
for order in orders:
- order.submit_for_signature(order.owner)
- order.contract.submitted_for_signature_on = timezone.now()
- order.contract.student_signed_on = timezone.now()
- order.contract.save()
+ factories.ContractFactory(
+ order=order,
+ student_signed_on=timezone.now(),
+ submitted_for_signature_on=timezone.now(),
+ )
+ order.init_flow(billing_address=BillingAddressDictFactory())
token = self.generate_token_from_user(access.user)
@@ -127,11 +151,10 @@ def test_api_organization_contracts_signature_link_specified_ids(self):
HTTP_AUTHORIZATION=f"Bearer {token}",
data={"contract_ids": [orders[0].contract.id]},
)
-
self.assertEqual(response.status_code, HTTPStatus.OK)
content = response.json()
self.assertIn(
- "https://dummysignaturebackend.fr/?requestToken=",
+ "https://dummysignaturebackend.fr/?reference=",
content["invitation_link"],
)
@@ -142,18 +165,12 @@ def test_api_organization_contracts_signature_link_exclude_canceled_orders(self)
Authenticated users with owner role should be able to sign contracts in bulk but
not validated orders should be excluded.
"""
- order = factories.OrderFactory(
- state=enums.ORDER_STATE_VALIDATED,
- product__contract_definition=factories.ContractDefinitionFactory(),
- )
+ # Simulate the user has signed its contract then later canceled its order
+ order = factories.OrderGeneratorFactory(state=enums.ORDER_STATE_PENDING)
+ order.flow.cancel()
access = factories.UserOrganizationAccessFactory(
organization=order.organization, role="owner"
)
- order.submit_for_signature(order.owner)
- order.contract.submitted_for_signature_on = timezone.now()
- order.contract.student_signed_on = timezone.now()
- # Simulate the user has signed its contract then later canceled its order
- order.flow.cancel()
token = self.generate_token_from_user(access.user)
@@ -202,10 +219,12 @@ def test_api_organization_contracts_signature_link_specified_course_product_rela
relation = factories.CourseProductRelationFactory(
organizations=[organization, other_organization],
product__contract_definition=factories.ContractDefinitionFactory(),
+ product__price=0,
)
relation_2 = factories.CourseProductRelationFactory(
organizations=[organization],
product__contract_definition=factories.ContractDefinitionFactory(),
+ product__price=0,
)
access = factories.UserOrganizationAccessFactory(
organization=organization, role="owner"
@@ -216,7 +235,13 @@ def test_api_organization_contracts_signature_link_specified_course_product_rela
product=relation.product,
course=relation.course,
organization=organization,
- state=enums.ORDER_STATE_VALIDATED,
+ payment_schedule=[
+ {
+ "amount": "200.00",
+ "due_date": "2024-01-17",
+ "state": enums.PAYMENT_STATE_PAID,
+ }
+ ],
)
contracts = []
@@ -229,6 +254,7 @@ def test_api_organization_contracts_signature_link_specified_course_product_rela
signature_backend_reference=f"wlf_{timezone.now()}",
)
)
+ order.init_flow()
# Create a contract linked to the same course product relation
# but for another organization
@@ -237,7 +263,7 @@ def test_api_organization_contracts_signature_link_specified_course_product_rela
product=relation.product,
course=relation.course,
organization=other_organization,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
),
student_signed_on=timezone.now(),
submitted_for_signature_on=timezone.now(),
@@ -251,7 +277,13 @@ def test_api_organization_contracts_signature_link_specified_course_product_rela
product=relation_2.product,
course=relation_2.course,
organization=organization,
- state=enums.ORDER_STATE_VALIDATED,
+ payment_schedule=[
+ {
+ "amount": "200.00",
+ "due_date": "2024-01-17",
+ "state": enums.PAYMENT_STATE_PAID,
+ }
+ ],
)
for order in other_orders:
@@ -261,6 +293,7 @@ def test_api_organization_contracts_signature_link_specified_course_product_rela
submitted_for_signature_on=timezone.now(),
signature_backend_reference=f"wlf_{timezone.now()}",
)
+ order.init_flow()
token = self.generate_token_from_user(access.user)
@@ -273,7 +306,7 @@ def test_api_organization_contracts_signature_link_specified_course_product_rela
self.assertEqual(response.status_code, HTTPStatus.OK)
content = response.json()
self.assertIn(
- "https://dummysignaturebackend.fr/?requestToken=",
+ "https://dummysignaturebackend.fr/?reference=",
content["invitation_link"],
)
@@ -291,18 +324,24 @@ def test_api_organization_contracts_signature_link_cumulative_filters(self):
2,
organizations=[organization],
product__contract_definition=factories.ContractDefinitionFactory(),
+ product__price=0,
)
access = factories.UserOrganizationAccessFactory(
organization=organization, role="owner"
)
-
# Create two contracts for the same organization and course product relation
orders = factories.OrderFactory.create_batch(
2,
product=relation.product,
course=relation.course,
organization=organization,
- state=enums.ORDER_STATE_VALIDATED,
+ payment_schedule=[
+ {
+ "amount": "200.00",
+ "due_date": "2024-01-17",
+ "state": enums.PAYMENT_STATE_PAID,
+ }
+ ],
)
contract = None
for order in orders:
@@ -312,6 +351,7 @@ def test_api_organization_contracts_signature_link_cumulative_filters(self):
submitted_for_signature_on=timezone.now(),
signature_backend_reference=f"wlf_{timezone.now()}",
)
+ order.init_flow()
token = self.generate_token_from_user(access.user)
@@ -327,7 +367,7 @@ def test_api_organization_contracts_signature_link_cumulative_filters(self):
self.assertEqual(response.status_code, HTTPStatus.OK)
content = response.json()
self.assertIn(
- "https://dummysignaturebackend.fr/?requestToken=",
+ "https://dummysignaturebackend.fr/?reference=",
content["invitation_link"],
)
diff --git a/src/backend/joanie/tests/core/api/remote_endpoints/course_run/test_course_run.py b/src/backend/joanie/tests/core/api/remote_endpoints/course_run/test_course_run.py
index ef3caf051..7cf1df363 100644
--- a/src/backend/joanie/tests/core/api/remote_endpoints/course_run/test_course_run.py
+++ b/src/backend/joanie/tests/core/api/remote_endpoints/course_run/test_course_run.py
@@ -640,7 +640,7 @@ def test_remote_endpoints_course_run_another_server_valid_token_enrollments_by_r
course=None,
product__type=enums.PRODUCT_TYPE_CERTIFICATE,
product__courses=[enrollment.course_run.course],
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
# Close the course run enrollments and set the end date to have "archived" state
closing_date = django_timezone.now() - timedelta(days=1)
diff --git a/src/backend/joanie/tests/core/models/order/test_factory.py b/src/backend/joanie/tests/core/models/order/test_factory.py
new file mode 100644
index 000000000..847a58852
--- /dev/null
+++ b/src/backend/joanie/tests/core/models/order/test_factory.py
@@ -0,0 +1,256 @@
+"""Test suite for the OrderGeneratorFactory and OrderFactory"""
+
+from datetime import date
+
+from django.test import TestCase, override_settings
+
+from joanie.core.enums import (
+ ORDER_STATE_ASSIGNED,
+ ORDER_STATE_CANCELED,
+ ORDER_STATE_COMPLETED,
+ ORDER_STATE_DRAFT,
+ ORDER_STATE_FAILED_PAYMENT,
+ ORDER_STATE_NO_PAYMENT,
+ ORDER_STATE_PENDING,
+ ORDER_STATE_PENDING_PAYMENT,
+ ORDER_STATE_SIGNING,
+ ORDER_STATE_TO_SAVE_PAYMENT_METHOD,
+ ORDER_STATE_TO_SIGN,
+ PAYMENT_STATE_PAID,
+ PAYMENT_STATE_PENDING,
+ PAYMENT_STATE_REFUSED,
+)
+from joanie.core.exceptions import InvalidConversionError
+from joanie.core.factories import OrderFactory, OrderGeneratorFactory
+
+
+@override_settings(
+ JOANIE_PAYMENT_SCHEDULE_LIMITS={
+ 5: (30, 70),
+ 10: (30, 45, 45),
+ 100: (20, 30, 30, 20),
+ },
+ DEFAULT_CURRENCY="EUR",
+)
+class TestOrderGeneratorFactory(TestCase):
+ """Test suite for the OrderGeneratorFactory."""
+
+ # pylint: disable=too-many-arguments
+ # ruff: noqa: PLR0913
+ def check_order(
+ self,
+ state,
+ has_organization,
+ has_unsigned_contract,
+ is_free,
+ has_payment_method,
+ ):
+ """Check the properties of an order based on the provided parameters."""
+ order = OrderGeneratorFactory(state=state, product__price=100)
+ if has_organization:
+ self.assertIsNotNone(order.organization)
+ else:
+ self.assertIsNone(order.organization)
+ self.assertEqual(order.has_unsigned_contract, has_unsigned_contract)
+ self.assertEqual(order.is_free, is_free)
+ self.assertEqual(order.has_payment_method, has_payment_method)
+ self.assertEqual(order.state, state)
+ return order
+
+ def test_factory_order_draft(self):
+ """Test the OrderGeneratorFactory with the state ORDER_STATE_DRAFT."""
+ self.check_order(
+ ORDER_STATE_DRAFT,
+ has_organization=False,
+ has_unsigned_contract=False,
+ is_free=False,
+ has_payment_method=False,
+ )
+
+ def test_factory_order_assigned(self):
+ """Test the OrderGeneratorFactory with the state ORDER_STATE_ASSIGNED."""
+ self.check_order(
+ ORDER_STATE_ASSIGNED,
+ has_organization=True,
+ has_unsigned_contract=False,
+ is_free=False,
+ has_payment_method=False,
+ )
+
+ def test_factory_order_to_sign(self):
+ """Test the OrderGeneratorFactory with the state ORDER_STATE_TO_SIGN."""
+ self.check_order(
+ ORDER_STATE_TO_SIGN,
+ has_organization=True,
+ has_unsigned_contract=True,
+ is_free=False,
+ has_payment_method=False,
+ )
+
+ def test_factory_order_signing(self):
+ """Test the OrderGeneratorFactory with the state ORDER_STATE_SIGNING."""
+ self.check_order(
+ ORDER_STATE_SIGNING,
+ has_organization=True,
+ has_unsigned_contract=True,
+ is_free=False,
+ has_payment_method=False,
+ )
+
+ def test_factory_order_to_save_payment_method(self):
+ """Test the OrderGeneratorFactory with the state ORDER_STATE_TO_SAVE_PAYMENT_METHOD."""
+ self.check_order(
+ ORDER_STATE_TO_SAVE_PAYMENT_METHOD,
+ has_organization=True,
+ has_unsigned_contract=False,
+ is_free=False,
+ has_payment_method=False,
+ )
+
+ def test_factory_order_pending(self):
+ """Test the OrderGeneratorFactory with the state ORDER_STATE_PENDING."""
+ order = self.check_order(
+ ORDER_STATE_PENDING,
+ has_organization=True,
+ has_unsigned_contract=False,
+ is_free=False,
+ has_payment_method=True,
+ )
+ self.assertEqual(order.payment_schedule[0]["state"], PAYMENT_STATE_PENDING)
+ self.assertEqual(order.payment_schedule[1]["state"], PAYMENT_STATE_PENDING)
+ self.assertEqual(order.payment_schedule[2]["state"], PAYMENT_STATE_PENDING)
+
+ def test_factory_order_pending_payment(self):
+ """Test the OrderGeneratorFactory with the state ORDER_STATE_PENDING_PAYMENT."""
+ order = self.check_order(
+ ORDER_STATE_PENDING_PAYMENT,
+ has_organization=True,
+ has_unsigned_contract=False,
+ is_free=False,
+ has_payment_method=True,
+ )
+ self.assertEqual(order.payment_schedule[0]["state"], PAYMENT_STATE_PAID)
+ self.assertEqual(order.payment_schedule[1]["state"], PAYMENT_STATE_PENDING)
+ self.assertEqual(order.payment_schedule[2]["state"], PAYMENT_STATE_PENDING)
+
+ def test_factory_order_no_payment(self):
+ """Test the OrderGeneratorFactory with the state ORDER_STATE_NO_PAYMENT."""
+ order = self.check_order(
+ ORDER_STATE_NO_PAYMENT,
+ has_organization=True,
+ has_unsigned_contract=False,
+ is_free=False,
+ has_payment_method=True,
+ )
+ self.assertEqual(order.payment_schedule[0]["state"], PAYMENT_STATE_REFUSED)
+ self.assertEqual(order.payment_schedule[1]["state"], PAYMENT_STATE_PENDING)
+ self.assertEqual(order.payment_schedule[2]["state"], PAYMENT_STATE_PENDING)
+
+ def test_factory_order_failed_payment(self):
+ """Test the OrderGeneratorFactory with the state ORDER_STATE_FAILED_PAYMENT."""
+ order = self.check_order(
+ ORDER_STATE_FAILED_PAYMENT,
+ has_organization=True,
+ has_unsigned_contract=False,
+ is_free=False,
+ has_payment_method=True,
+ )
+ self.assertEqual(order.payment_schedule[0]["state"], PAYMENT_STATE_PAID)
+ self.assertEqual(order.payment_schedule[1]["state"], PAYMENT_STATE_REFUSED)
+ self.assertEqual(order.payment_schedule[2]["state"], PAYMENT_STATE_PENDING)
+
+ def test_factory_order_completed(self):
+ """Test the OrderGeneratorFactory with the state ORDER_STATE_COMPLETED."""
+ order = self.check_order(
+ ORDER_STATE_COMPLETED,
+ has_organization=True,
+ has_unsigned_contract=False,
+ is_free=False,
+ has_payment_method=True,
+ )
+ self.assertEqual(order.payment_schedule[0]["state"], PAYMENT_STATE_PAID)
+ self.assertEqual(order.payment_schedule[1]["state"], PAYMENT_STATE_PAID)
+ self.assertEqual(order.payment_schedule[2]["state"], PAYMENT_STATE_PAID)
+
+ def test_factory_order_canceled(self):
+ """Test the OrderGeneratorFactory with the state ORDER_STATE_CANCELED."""
+ order = self.check_order(
+ ORDER_STATE_CANCELED,
+ has_organization=True,
+ has_unsigned_contract=False,
+ is_free=False,
+ has_payment_method=True,
+ )
+ self.assertEqual(order.payment_schedule[0]["state"], PAYMENT_STATE_PENDING)
+ self.assertEqual(order.payment_schedule[1]["state"], PAYMENT_STATE_PENDING)
+ self.assertEqual(order.payment_schedule[2]["state"], PAYMENT_STATE_PENDING)
+
+ def test_factory_order_passed_isoformat_string_due_date_value_to_convert_to_date_object(
+ self,
+ ):
+ """
+ When passing a string of date in isoformat for the due_date, it should transform
+ that string into a date object.
+ """
+ order = OrderGeneratorFactory(
+ state=ORDER_STATE_PENDING_PAYMENT, product__price=100
+ )
+ order.payment_schedule[0]["due_date"] = "2024-09-01"
+
+ order.refresh_from_db()
+
+ self.assertIsInstance(order.payment_schedule[0]["due_date"], date)
+
+
+class TestOrderFactory(TestCase):
+ """Test suite for the `OrderFactory`."""
+
+ def test_factory_order_payment_schedule_due_date_wrong_format_raise_invalid_conversion_error(
+ self,
+ ):
+ """
+ Test that `OrderFactory` raises an `InvalidConversionError` when a string with an
+ incorrect ISO date format is passed as the `due_date` in the payment schedule.
+ """
+ with self.assertRaises(InvalidConversionError) as context:
+ OrderFactory(
+ state=ORDER_STATE_PENDING_PAYMENT,
+ payment_schedule=[
+ {
+ "id": "1932fbc5-d971-48aa-8fee-6d637c3154a5",
+ "due_date": "abc01-6-22",
+ "amount": "200.00",
+ "state": PAYMENT_STATE_PENDING,
+ }
+ ],
+ )
+
+ self.assertEqual(
+ str(context.exception),
+ "Invalid date format for date_str: Invalid isoformat string: 'abc01-6-22'.",
+ )
+
+ def test_factory_order_payment_schedule_amount_wrong_format_raise_invalid_conversion_error(
+ self,
+ ):
+ """
+ Test that `OrderFactory` raises an `InvalidConversionError` when a string with an
+ incorrect amount value is passed as the `amount` in the payment schedule.
+ """
+ with self.assertRaises(InvalidConversionError) as context:
+ OrderFactory(
+ state=ORDER_STATE_PENDING_PAYMENT,
+ payment_schedule=[
+ {
+ "id": "a1cf9f39-594f-4528-a657-a0b9018b90ad",
+ "due_date": "2024-09-01",
+ "amount": "abc02",
+ "state": PAYMENT_STATE_PENDING,
+ }
+ ],
+ )
+
+ self.assertEqual(
+ str(context.exception),
+ "Invalid format for amount: Input value cannot be used as monetary amount : 'abc02'.",
+ )
diff --git a/src/backend/joanie/tests/core/models/order/test_schedule.py b/src/backend/joanie/tests/core/models/order/test_schedule.py
index 05cce8c2d..322612b15 100644
--- a/src/backend/joanie/tests/core/models/order/test_schedule.py
+++ b/src/backend/joanie/tests/core/models/order/test_schedule.py
@@ -31,6 +31,7 @@
from joanie.tests.base import ActivityLogMixingTestCase, BaseLogMixinTestCase
+# pylint: disable=too-many-public-methods
@override_settings(
JOANIE_PAYMENT_SCHEDULE_LIMITS={
5: (30, 70),
@@ -46,9 +47,9 @@ class OrderModelsTestCase(TestCase, BaseLogMixinTestCase, ActivityLogMixingTestC
maxDiff = None
- def test_models_order_schedule_get_schedule_dates(self):
+ def test_models_order_schedule_get_schedule_dates_with_contract(self):
"""
- Check that the schedule dates are correctly calculated
+ Check that the schedule dates are correctly calculated for order with contract
"""
student_signed_on_date = datetime(2024, 1, 1, 14, tzinfo=ZoneInfo("UTC"))
course_run_start_date = datetime(2024, 3, 1, 14, tzinfo=ZoneInfo("UTC"))
@@ -64,13 +65,37 @@ def test_models_order_schedule_get_schedule_dates(self):
order__product__target_courses=[course_run.course],
)
+ signed_contract_date, course_start_date, course_end_date = (
+ contract.order._get_schedule_dates()
+ )
+
+ self.assertEqual(signed_contract_date, student_signed_on_date)
+ self.assertEqual(course_start_date, course_run_start_date)
+ self.assertEqual(course_end_date, course_run_end_date)
+
+ def test_models_order_schedule_get_schedule_dates_without_contract(self):
+ """
+ Check that the schedule dates are correctly calculated for order without contract
+ """
+ course_run_start_date = datetime(2024, 3, 1, 14, tzinfo=ZoneInfo("UTC"))
+ course_run_end_date = datetime(2024, 5, 1, 14, tzinfo=ZoneInfo("UTC"))
+ course_run = factories.CourseRunFactory(
+ enrollment_start=datetime(2024, 1, 1, 8, tzinfo=ZoneInfo("UTC")),
+ start=course_run_start_date,
+ end=course_run_end_date,
+ )
+ order = factories.OrderFactory(
+ state=ORDER_STATE_COMPLETED,
+ product__target_courses=[course_run.course],
+ )
+
mocked_now = datetime(2024, 1, 1, 14, tzinfo=ZoneInfo("UTC"))
with mock.patch("django.utils.timezone.now", return_value=mocked_now):
signed_contract_date, course_start_date, course_end_date = (
- contract.order._get_schedule_dates()
+ order._get_schedule_dates()
)
- self.assertEqual(signed_contract_date, student_signed_on_date)
+ self.assertEqual(signed_contract_date, mocked_now)
self.assertEqual(course_start_date, course_run_start_date)
self.assertEqual(course_end_date, course_run_end_date)
@@ -105,7 +130,7 @@ def test_models_order_schedule_get_schedule_dates_no_course_run(self):
def test_models_order_schedule_2_parts(self):
"""
- Check that order's schedule is correctly set for 1 part
+ Check that order's schedule is correctly set for 2 parts
"""
course_run = factories.CourseRunFactory(
enrollment_start=datetime(2024, 1, 1, 14, tzinfo=ZoneInfo("UTC")),
@@ -152,14 +177,165 @@ def test_models_order_schedule_2_parts(self):
[
{
"id": str(first_uuid),
- "amount": "0.90",
- "due_date": "2024-01-17",
+ "amount": Money("0.90"),
+ "due_date": date(2024, 1, 17),
+ "state": PAYMENT_STATE_PENDING,
+ },
+ {
+ "id": str(second_uuid),
+ "amount": Money("2.10"),
+ "due_date": date(2024, 3, 1),
+ "state": PAYMENT_STATE_PENDING,
+ },
+ ],
+ )
+
+ def test_models_order_schedule_3_parts(self):
+ """
+ Check that order's schedule is correctly set for 3 parts
+ """
+ course_run = factories.CourseRunFactory(
+ enrollment_start=datetime(2024, 1, 1, 14, tzinfo=ZoneInfo("UTC")),
+ start=datetime(2024, 3, 1, 14, tzinfo=ZoneInfo("UTC")),
+ end=datetime(2024, 5, 1, 14, tzinfo=ZoneInfo("UTC")),
+ )
+ contract = factories.ContractFactory(
+ student_signed_on=datetime(2024, 1, 1, 14, tzinfo=ZoneInfo("UTC")),
+ submitted_for_signature_on=datetime(2024, 1, 1, 14, tzinfo=ZoneInfo("UTC")),
+ order__product__price=6,
+ order__product__target_courses=[course_run.course],
+ )
+ first_uuid = uuid.UUID("1932fbc5-d971-48aa-8fee-6d637c3154a5")
+ second_uuid = uuid.UUID("a1cf9f39-594f-4528-a657-a0b9018b90ad")
+ third_uuid = uuid.UUID("a1cf9f39-594f-4528-a657-a0b9018b90ad")
+ mocked_now = datetime(2024, 1, 1, 14, tzinfo=ZoneInfo("UTC"))
+ with (
+ mock.patch.object(payment_schedule.uuid, "uuid4") as uuid4_mock,
+ mock.patch("django.utils.timezone.now", return_value=mocked_now),
+ ):
+ uuid4_mock.side_effect = [first_uuid, second_uuid, third_uuid]
+ schedule = contract.order.generate_schedule()
+
+ self.assertEqual(
+ schedule,
+ [
+ {
+ "id": first_uuid,
+ "amount": Money(1.80, settings.DEFAULT_CURRENCY),
+ "due_date": date(2024, 1, 17),
+ "state": PAYMENT_STATE_PENDING,
+ },
+ {
+ "id": second_uuid,
+ "amount": Money(2.70, settings.DEFAULT_CURRENCY),
+ "due_date": date(2024, 3, 1),
+ "state": PAYMENT_STATE_PENDING,
+ },
+ {
+ "id": third_uuid,
+ "amount": Money(1.50, settings.DEFAULT_CURRENCY),
+ "due_date": date(2024, 4, 1),
+ "state": PAYMENT_STATE_PENDING,
+ },
+ ],
+ )
+
+ contract.order.refresh_from_db()
+ self.assertEqual(
+ contract.order.payment_schedule,
+ [
+ {
+ "id": str(first_uuid),
+ "amount": Money("1.80"),
+ "due_date": date(2024, 1, 17),
"state": PAYMENT_STATE_PENDING,
},
{
"id": str(second_uuid),
- "amount": "2.10",
- "due_date": "2024-03-01",
+ "amount": Money("2.70"),
+ "due_date": date(2024, 3, 1),
+ "state": PAYMENT_STATE_PENDING,
+ },
+ {
+ "id": str(third_uuid),
+ "amount": Money("1.50"),
+ "due_date": date(2024, 4, 1),
+ "state": PAYMENT_STATE_PENDING,
+ },
+ ],
+ )
+
+ def test_models_order_schedule_3_parts_session_already_started(self):
+ """
+ Check that order's schedule is correctly set for 3 parts
+ when the session has already started
+ """
+ course_run = factories.CourseRunFactory(
+ enrollment_start=datetime(2023, 1, 1, 14, tzinfo=ZoneInfo("UTC")),
+ start=datetime(2023, 3, 1, 14, tzinfo=ZoneInfo("UTC")),
+ end=datetime(2024, 5, 1, 14, tzinfo=ZoneInfo("UTC")),
+ )
+ contract = factories.ContractFactory(
+ student_signed_on=datetime(2024, 1, 1, 14, tzinfo=ZoneInfo("UTC")),
+ submitted_for_signature_on=datetime(2024, 1, 1, 14, tzinfo=ZoneInfo("UTC")),
+ order__product__price=6,
+ order__product__target_courses=[course_run.course],
+ )
+ first_uuid = uuid.UUID("1932fbc5-d971-48aa-8fee-6d637c3154a5")
+ second_uuid = uuid.UUID("a1cf9f39-594f-4528-a657-a0b9018b90ad")
+ third_uuid = uuid.UUID("a1cf9f39-594f-4528-a657-a0b9018b90ad")
+ mocked_now = datetime(2024, 1, 1, 14, tzinfo=ZoneInfo("UTC"))
+ with (
+ mock.patch.object(payment_schedule.uuid, "uuid4") as uuid4_mock,
+ mock.patch("django.utils.timezone.now", return_value=mocked_now),
+ ):
+ uuid4_mock.side_effect = [first_uuid, second_uuid, third_uuid]
+ schedule = contract.order.generate_schedule()
+
+ self.assertEqual(
+ schedule,
+ [
+ {
+ "id": first_uuid,
+ "amount": Money(1.80, settings.DEFAULT_CURRENCY),
+ "due_date": date(2024, 1, 1),
+ "state": PAYMENT_STATE_PENDING,
+ },
+ {
+ "id": second_uuid,
+ "amount": Money(2.70, settings.DEFAULT_CURRENCY),
+ "due_date": date(2024, 2, 1),
+ "state": PAYMENT_STATE_PENDING,
+ },
+ {
+ "id": third_uuid,
+ "amount": Money(1.50, settings.DEFAULT_CURRENCY),
+ "due_date": date(2024, 3, 1),
+ "state": PAYMENT_STATE_PENDING,
+ },
+ ],
+ )
+
+ contract.order.refresh_from_db()
+ self.assertEqual(
+ contract.order.payment_schedule,
+ [
+ {
+ "id": str(first_uuid),
+ "amount": Money("1.80"),
+ "due_date": date(2024, 1, 1),
+ "state": PAYMENT_STATE_PENDING,
+ },
+ {
+ "id": str(second_uuid),
+ "amount": Money("2.70"),
+ "due_date": date(2024, 2, 1),
+ "state": PAYMENT_STATE_PENDING,
+ },
+ {
+ "id": str(third_uuid),
+ "amount": Money("1.50"),
+ "due_date": date(2024, 3, 1),
"state": PAYMENT_STATE_PENDING,
},
],
@@ -220,7 +396,7 @@ def test_models_order_schedule_find_installment(self):
self.assertEqual(len(found_orders), 1)
self.assertIn(order, found_orders)
- def test_models_order_schedule_find_today_installments(self):
+ def test_models_order_schedule_find_pending_installments(self):
"""Check that matching orders are found"""
order = factories.OrderFactory(
state=ORDER_STATE_PENDING,
@@ -240,6 +416,11 @@ def test_models_order_schedule_find_today_installments(self):
order_2 = factories.OrderFactory(
state=ORDER_STATE_PENDING_PAYMENT,
payment_schedule=[
+ {
+ "amount": "200.00",
+ "due_date": "2024-01-17",
+ "state": PAYMENT_STATE_PAID,
+ },
{
"amount": "300.00",
"due_date": "2024-02-17",
@@ -252,21 +433,22 @@ def test_models_order_schedule_find_today_installments(self):
},
],
)
- factories.OrderFactory(
- state=ORDER_STATE_PENDING_PAYMENT,
+ order_3 = factories.OrderFactory(
+ state=ORDER_STATE_PENDING,
payment_schedule=[
- {
- "amount": "200.00",
- "due_date": "2024-01-18",
- "state": PAYMENT_STATE_PENDING,
- },
{
"amount": "300.00",
- "due_date": "2024-02-18",
+ "due_date": "2024-03-18",
"state": PAYMENT_STATE_REFUSED,
},
+ {
+ "amount": "199.99",
+ "due_date": "2024-04-18",
+ "state": PAYMENT_STATE_PENDING,
+ },
],
)
+
factories.OrderFactory(
state=ORDER_STATE_PENDING_PAYMENT,
payment_schedule=[
@@ -279,27 +461,32 @@ def test_models_order_schedule_find_today_installments(self):
)
factories.OrderFactory(
- state=ORDER_STATE_PENDING,
+ state=ORDER_STATE_NO_PAYMENT,
payment_schedule=[
{
- "amount": "199.99",
- "due_date": "2024-04-18",
+ "amount": "200.00",
+ "due_date": "2024-01-18",
+ "state": PAYMENT_STATE_REFUSED,
+ },
+ {
+ "amount": "200.00",
+ "due_date": "2024-01-18",
"state": PAYMENT_STATE_PENDING,
},
],
)
- mocked_now = datetime(2024, 2, 17, 1, 10, tzinfo=ZoneInfo("UTC"))
- with mock.patch("django.utils.timezone.now", return_value=mocked_now):
- found_orders = Order.objects.find_today_installments()
+ found_orders = Order.objects.find_pending_installments()
- self.assertEqual(len(found_orders), 2)
+ self.assertEqual(len(found_orders), 3)
self.assertIn(order, found_orders)
self.assertIn(order_2, found_orders)
+ self.assertIn(order_3, found_orders)
def test_models_order_schedule_set_installment_state(self):
"""Check that the state of an installment can be set."""
order = factories.OrderFactory(
+ state=ORDER_STATE_PENDING_PAYMENT,
payment_schedule=[
{
"id": "d9356dd7-19a6-4695-b18e-ad93af41424a",
@@ -328,7 +515,7 @@ def test_models_order_schedule_set_installment_state(self):
],
)
- is_first, is_last = order._set_installment_state(
+ order._set_installment_state(
installment_id="d9356dd7-19a6-4695-b18e-ad93af41424a",
state=PAYMENT_STATE_PAID,
)
@@ -339,34 +526,32 @@ def test_models_order_schedule_set_installment_state(self):
[
{
"id": "d9356dd7-19a6-4695-b18e-ad93af41424a",
- "amount": "200.00",
- "due_date": "2024-01-17",
+ "amount": Money("200.00"),
+ "due_date": date(2024, 1, 17),
"state": PAYMENT_STATE_PAID,
},
{
"id": "1932fbc5-d971-48aa-8fee-6d637c3154a5",
- "amount": "300.00",
- "due_date": "2024-02-17",
+ "amount": Money("300.00"),
+ "due_date": date(2024, 2, 17),
"state": PAYMENT_STATE_PENDING,
},
{
"id": "168d7e8c-a1a9-4d70-9667-853bf79e502c",
- "amount": "300.00",
- "due_date": "2024-03-17",
+ "amount": Money("300.00"),
+ "due_date": date(2024, 3, 17),
"state": PAYMENT_STATE_PENDING,
},
{
"id": "9fcff723-7be4-4b77-87c6-2865e000f879",
- "amount": "199.99",
- "due_date": "2024-04-17",
+ "amount": Money("199.99"),
+ "due_date": date(2024, 4, 17),
"state": PAYMENT_STATE_PENDING,
},
],
)
- self.assertTrue(is_first)
- self.assertFalse(is_last)
- is_first, is_last = order._set_installment_state(
+ order._set_installment_state(
installment_id="9fcff723-7be4-4b77-87c6-2865e000f879",
state=PAYMENT_STATE_REFUSED,
)
@@ -377,32 +562,30 @@ def test_models_order_schedule_set_installment_state(self):
[
{
"id": "d9356dd7-19a6-4695-b18e-ad93af41424a",
- "amount": "200.00",
- "due_date": "2024-01-17",
+ "amount": Money("200.00"),
+ "due_date": date(2024, 1, 17),
"state": PAYMENT_STATE_PAID,
},
{
"id": "1932fbc5-d971-48aa-8fee-6d637c3154a5",
- "amount": "300.00",
- "due_date": "2024-02-17",
+ "amount": Money("300.00"),
+ "due_date": date(2024, 2, 17),
"state": PAYMENT_STATE_PENDING,
},
{
"id": "168d7e8c-a1a9-4d70-9667-853bf79e502c",
- "amount": "300.00",
- "due_date": "2024-03-17",
+ "amount": Money("300.00"),
+ "due_date": date(2024, 3, 17),
"state": PAYMENT_STATE_PENDING,
},
{
"id": "9fcff723-7be4-4b77-87c6-2865e000f879",
- "amount": "199.99",
- "due_date": "2024-04-17",
+ "amount": Money("199.99"),
+ "due_date": date(2024, 4, 17),
"state": PAYMENT_STATE_REFUSED,
},
],
)
- self.assertFalse(is_first)
- self.assertTrue(is_last)
with self.assertRaises(ValueError):
order._set_installment_state(
@@ -417,7 +600,7 @@ def test_models_order_schedule_set_installment_paid(self):
should be set to pending payment.
"""
order = factories.OrderFactory(
- state=ORDER_STATE_PENDING,
+ state=ORDER_STATE_PENDING_PAYMENT,
payment_schedule=[
{
"id": "d9356dd7-19a6-4695-b18e-ad93af41424a",
@@ -456,26 +639,26 @@ def test_models_order_schedule_set_installment_paid(self):
[
{
"id": "d9356dd7-19a6-4695-b18e-ad93af41424a",
- "amount": "200.00",
- "due_date": "2024-01-17",
+ "amount": Money("200.00"),
+ "due_date": date(2024, 1, 17),
"state": PAYMENT_STATE_PAID,
},
{
"id": "1932fbc5-d971-48aa-8fee-6d637c3154a5",
- "amount": "300.00",
- "due_date": "2024-02-17",
+ "amount": Money("300.00"),
+ "due_date": date(2024, 2, 17),
"state": PAYMENT_STATE_PAID,
},
{
"id": "168d7e8c-a1a9-4d70-9667-853bf79e502c",
- "amount": "300.00",
- "due_date": "2024-03-17",
+ "amount": Money("300.00"),
+ "due_date": date(2024, 3, 17),
"state": PAYMENT_STATE_PENDING,
},
{
"id": "9fcff723-7be4-4b77-87c6-2865e000f879",
- "amount": "199.99",
- "due_date": "2024-04-17",
+ "amount": Money("199.99"),
+ "due_date": date(2024, 4, 17),
"state": PAYMENT_STATE_PENDING,
},
],
@@ -529,26 +712,26 @@ def test_models_order_schedule_set_installment_paid_first(self):
[
{
"id": "d9356dd7-19a6-4695-b18e-ad93af41424a",
- "amount": "200.00",
- "due_date": "2024-01-17",
+ "amount": Money("200.00"),
+ "due_date": date(2024, 1, 17),
"state": PAYMENT_STATE_PAID,
},
{
"id": "1932fbc5-d971-48aa-8fee-6d637c3154a5",
- "amount": "300.00",
- "due_date": "2024-02-17",
+ "amount": Money("300.00"),
+ "due_date": date(2024, 2, 17),
"state": PAYMENT_STATE_PENDING,
},
{
"id": "168d7e8c-a1a9-4d70-9667-853bf79e502c",
- "amount": "300.00",
- "due_date": "2024-03-17",
+ "amount": Money("300.00"),
+ "due_date": date(2024, 3, 17),
"state": PAYMENT_STATE_PENDING,
},
{
"id": "9fcff723-7be4-4b77-87c6-2865e000f879",
- "amount": "199.99",
- "due_date": "2024-04-17",
+ "amount": Money("199.99"),
+ "due_date": date(2024, 4, 17),
"state": PAYMENT_STATE_PENDING,
},
],
@@ -602,26 +785,26 @@ def test_models_order_schedule_set_installment_paid_last(self):
[
{
"id": "d9356dd7-19a6-4695-b18e-ad93af41424a",
- "amount": "200.00",
- "due_date": "2024-01-17",
+ "amount": Money("200.00"),
+ "due_date": date(2024, 1, 17),
"state": PAYMENT_STATE_PAID,
},
{
"id": "1932fbc5-d971-48aa-8fee-6d637c3154a5",
- "amount": "300.00",
- "due_date": "2024-02-17",
+ "amount": Money("300.00"),
+ "due_date": date(2024, 2, 17),
"state": PAYMENT_STATE_PAID,
},
{
"id": "168d7e8c-a1a9-4d70-9667-853bf79e502c",
- "amount": "300.00",
- "due_date": "2024-03-17",
+ "amount": Money("300.00"),
+ "due_date": date(2024, 3, 17),
"state": PAYMENT_STATE_PAID,
},
{
"id": "9fcff723-7be4-4b77-87c6-2865e000f879",
- "amount": "199.99",
- "due_date": "2024-04-17",
+ "amount": Money("199.99"),
+ "due_date": date(2024, 4, 17),
"state": PAYMENT_STATE_PAID,
},
],
@@ -657,8 +840,8 @@ def test_models_order_schedule_set_installment_paid_unique(self):
[
{
"id": "d9356dd7-19a6-4695-b18e-ad93af41424a",
- "amount": "200.00",
- "due_date": "2024-01-17",
+ "amount": Money("200.00"),
+ "due_date": date(2024, 1, 17),
"state": PAYMENT_STATE_PAID,
},
],
@@ -712,26 +895,26 @@ def test_models_order_schedule_set_installment_refused(self):
[
{
"id": "d9356dd7-19a6-4695-b18e-ad93af41424a",
- "amount": "200.00",
- "due_date": "2024-01-17",
+ "amount": Money("200.00"),
+ "due_date": date(2024, 1, 17),
"state": PAYMENT_STATE_PAID,
},
{
"id": "1932fbc5-d971-48aa-8fee-6d637c3154a5",
- "amount": "300.00",
- "due_date": "2024-02-17",
+ "amount": Money("300.00"),
+ "due_date": date(2024, 2, 17),
"state": PAYMENT_STATE_REFUSED,
},
{
"id": "168d7e8c-a1a9-4d70-9667-853bf79e502c",
- "amount": "300.00",
- "due_date": "2024-03-17",
+ "amount": Money("300.00"),
+ "due_date": date(2024, 3, 17),
"state": PAYMENT_STATE_PENDING,
},
{
"id": "9fcff723-7be4-4b77-87c6-2865e000f879",
- "amount": "199.99",
- "due_date": "2024-04-17",
+ "amount": Money("199.99"),
+ "due_date": date(2024, 4, 17),
"state": PAYMENT_STATE_PENDING,
},
],
@@ -785,26 +968,26 @@ def test_models_order_schedule_set_installment_refused_first(self):
[
{
"id": "d9356dd7-19a6-4695-b18e-ad93af41424a",
- "amount": "200.00",
- "due_date": "2024-01-17",
+ "amount": Money("200.00"),
+ "due_date": date(2024, 1, 17),
"state": PAYMENT_STATE_REFUSED,
},
{
"id": "1932fbc5-d971-48aa-8fee-6d637c3154a5",
- "amount": "300.00",
- "due_date": "2024-02-17",
+ "amount": Money("300.00"),
+ "due_date": date(2024, 2, 17),
"state": PAYMENT_STATE_PENDING,
},
{
"id": "168d7e8c-a1a9-4d70-9667-853bf79e502c",
- "amount": "300.00",
- "due_date": "2024-03-17",
+ "amount": Money("300.00"),
+ "due_date": date(2024, 3, 17),
"state": PAYMENT_STATE_PENDING,
},
{
"id": "9fcff723-7be4-4b77-87c6-2865e000f879",
- "amount": "199.99",
- "due_date": "2024-04-17",
+ "amount": Money("199.99"),
+ "due_date": date(2024, 4, 17),
"state": PAYMENT_STATE_PENDING,
},
],
@@ -858,26 +1041,26 @@ def test_models_order_schedule_set_installment_refused_last(self):
[
{
"id": "d9356dd7-19a6-4695-b18e-ad93af41424a",
- "amount": "200.00",
- "due_date": "2024-01-17",
+ "amount": Money("200.00"),
+ "due_date": date(2024, 1, 17),
"state": PAYMENT_STATE_PAID,
},
{
"id": "1932fbc5-d971-48aa-8fee-6d637c3154a5",
- "amount": "300.00",
- "due_date": "2024-02-17",
+ "amount": Money("300.00"),
+ "due_date": date(2024, 2, 17),
"state": PAYMENT_STATE_PAID,
},
{
"id": "168d7e8c-a1a9-4d70-9667-853bf79e502c",
- "amount": "300.00",
- "due_date": "2024-03-17",
+ "amount": Money("300.00"),
+ "due_date": date(2024, 3, 17),
"state": PAYMENT_STATE_PAID,
},
{
"id": "9fcff723-7be4-4b77-87c6-2865e000f879",
- "amount": "199.99",
- "due_date": "2024-04-17",
+ "amount": Money("199.99"),
+ "due_date": date(2024, 4, 17),
"state": PAYMENT_STATE_REFUSED,
},
],
@@ -913,8 +1096,8 @@ def test_models_order_schedule_set_installment_refused_unique(self):
[
{
"id": "d9356dd7-19a6-4695-b18e-ad93af41424a",
- "amount": "200.00",
- "due_date": "2024-01-17",
+ "amount": Money("200.00"),
+ "due_date": date(2024, 1, 17),
"state": PAYMENT_STATE_REFUSED,
},
],
@@ -940,6 +1123,7 @@ def test_models_order_schedule_withdraw(self):
},
]
)
+ order.refresh_from_db()
mocked_now = datetime(2024, 1, 12, 8, 8, tzinfo=ZoneInfo("UTC"))
with mock.patch("django.utils.timezone.now", return_value=mocked_now):
@@ -975,6 +1159,7 @@ def test_models_order_schedule_withdraw_passed_due_date(self):
},
]
)
+ order.refresh_from_db()
mocked_now = datetime(2024, 2, 18, 8, 8)
with mock.patch("django.utils.timezone.now", return_value=mocked_now):
@@ -984,42 +1169,25 @@ def test_models_order_schedule_withdraw_passed_due_date(self):
):
order.withdraw()
- def test_models_order_get_first_installment_refused_returns_installment_object(
+ def test_models_order_schedule_get_first_installment_refused_returns_installment_object(
self,
):
"""
The method `get_first_installment_refused` should return the first installment found
that is in state `PAYMENT_STATE_REFUSED` in the payment schedule of an order.
"""
- order = factories.OrderFactory(
- state=ORDER_STATE_FAILED_PAYMENT,
- payment_schedule=[
- {
- "id": "d9356dd7-19a6-4695-b18e-ad93af41424a",
- "amount": "200.00",
- "due_date": "2024-01-17",
- "state": PAYMENT_STATE_PAID,
- },
- {
- "id": "1932fbc5-d971-48aa-8fee-6d637c3154a5",
- "amount": "300.00",
- "due_date": "2024-02-17",
- "state": PAYMENT_STATE_REFUSED,
- },
- {
- "id": "168d7e8c-a1a9-4d70-9667-853bf79e502c",
- "amount": "300.00",
- "due_date": "2024-03-17",
- "state": PAYMENT_STATE_PENDING,
- },
- {
- "id": "9fcff723-7be4-4b77-87c6-2865e000f879",
- "amount": "199.99",
- "due_date": "2024-04-17",
- "state": PAYMENT_STATE_PENDING,
- },
- ],
+ order = factories.OrderGeneratorFactory(
+ state=ORDER_STATE_PENDING_PAYMENT,
+ product__price=100,
)
+ order.payment_schedule[0]["state"] = PAYMENT_STATE_PAID
+ # Prepare data of the 'refused' state installment
+ order.payment_schedule[1]["id"] = "1932fbc5-d971-48aa-8fee-6d637c3154a5"
+ order.payment_schedule[1]["due_date"] = date(2024, 2, 17)
+ order.payment_schedule[1]["state"] = PAYMENT_STATE_REFUSED
+ # Set the rest of installments to 'pending' state
+ order.payment_schedule[2]["state"] = PAYMENT_STATE_PENDING
+ order.payment_schedule[3]["state"] = PAYMENT_STATE_PENDING
installment = order.get_first_installment_refused()
@@ -1027,47 +1195,207 @@ def test_models_order_get_first_installment_refused_returns_installment_object(
installment,
{
"id": "1932fbc5-d971-48aa-8fee-6d637c3154a5",
- "amount": "300.00",
- "due_date": "2024-02-17",
+ "amount": Money("30.00"),
+ "due_date": date(2024, 2, 17),
"state": PAYMENT_STATE_REFUSED,
},
)
- def test_models_order_get_first_installment_refused_returns_none(self):
+ def test_models_order_schedule_get_first_installment_refused_returns_none(self):
"""
The method `get_first_installment_refused` should return `None` if there is no installment
in payment schedule found for the order with the state `PAYMENT_STATE_REFUSED`.
"""
- order = factories.OrderFactory(
+ order = factories.OrderGeneratorFactory(
state=ORDER_STATE_PENDING_PAYMENT,
- payment_schedule=[
- {
- "id": "d9356dd7-19a6-4695-b18e-ad93af41424a",
- "amount": "200.00",
- "due_date": "2024-01-17",
- "state": PAYMENT_STATE_PAID,
- },
- {
- "id": "1932fbc5-d971-48aa-8fee-6d637c3154a5",
- "amount": "300.00",
- "due_date": "2024-02-17",
- "state": PAYMENT_STATE_PAID,
- },
- {
- "id": "168d7e8c-a1a9-4d70-9667-853bf79e502c",
- "amount": "300.00",
- "due_date": "2024-03-17",
- "state": PAYMENT_STATE_PAID,
- },
- {
- "id": "9fcff723-7be4-4b77-87c6-2865e000f879",
- "amount": "199.99",
- "due_date": "2024-04-17",
- "state": PAYMENT_STATE_PENDING,
- },
- ],
+ product__price=100,
)
+ order.payment_schedule[0]["state"] = PAYMENT_STATE_PAID
+ order.payment_schedule[1]["state"] = PAYMENT_STATE_PAID
+ order.payment_schedule[2]["state"] = PAYMENT_STATE_PAID
+ order.payment_schedule[3]["state"] = PAYMENT_STATE_PENDING
installment = order.get_first_installment_refused()
self.assertIsNone(installment)
+
+ def test_models_order_schedule_get_date_next_installment_to_pay(self):
+ """
+ Should return the date of the next installment to pay for the user on his order's
+ payment schedule.
+ """
+ order = factories.OrderGeneratorFactory(
+ state=ORDER_STATE_PENDING_PAYMENT,
+ product__price=100,
+ )
+ order.payment_schedule[0]["state"] = PAYMENT_STATE_PAID
+ order.payment_schedule[1]["state"] = PAYMENT_STATE_PAID
+ order.payment_schedule[2]["state"] = PAYMENT_STATE_PAID
+ order.payment_schedule[3]["due_date"] = date(2024, 4, 17)
+
+ date_next_installment = order.get_date_next_installment_to_pay()
+
+ self.assertEqual(date_next_installment, date(2024, 4, 17))
+
+ def test_models_order_get_date_next_installment_to_pay_returns_none_if_no_pending_state(
+ self,
+ ):
+ """
+ The method `get_date_next_installment_to_pay` should return None if there is no
+ `pending` state into the order's payment schedule.
+ """
+ order = factories.OrderGeneratorFactory(
+ state=ORDER_STATE_PENDING_PAYMENT,
+ product__price=5,
+ )
+ order.payment_schedule[0]["state"] = PAYMENT_STATE_PAID
+ order.payment_schedule[1]["state"] = PAYMENT_STATE_REFUSED
+
+ result = order.get_date_next_installment_to_pay()
+
+ self.assertIsNone(result)
+
+ def test_models_order_schedule_get_remaining_balance_to_pay(self):
+ """
+ Should return the leftover amount still remaining to be paid on an order's
+ payment schedule
+ """
+ order = factories.OrderGeneratorFactory(
+ state=ORDER_STATE_PENDING_PAYMENT,
+ product__price=100,
+ )
+ order.payment_schedule[0]["state"] = PAYMENT_STATE_PAID
+ order.payment_schedule[1]["state"] = PAYMENT_STATE_PAID
+
+ remains = order.get_remaining_balance_to_pay()
+
+ self.assertEqual(remains, Money("50.00"))
+
+ def test_models_order_schedule_get_index_of_last_installment_with_paid_state(self):
+ """
+ Test that the method `get_installment_index` correctly returns the index of the
+ last installment with the state 'paid' in the payment schedule.
+ """
+ order = factories.OrderGeneratorFactory(
+ state=ORDER_STATE_PENDING_PAYMENT,
+ product__price=100,
+ )
+ order.payment_schedule[0]["state"] = PAYMENT_STATE_PAID
+
+ self.assertEqual(
+ 0,
+ order.get_installment_index(state=PAYMENT_STATE_PAID),
+ )
+
+ order.payment_schedule[1]["state"] = PAYMENT_STATE_PAID
+
+ self.assertEqual(
+ 1,
+ order.get_installment_index(state=PAYMENT_STATE_PAID),
+ )
+
+ order.payment_schedule[2]["state"] = PAYMENT_STATE_PAID
+
+ self.assertEqual(
+ 2,
+ order.get_installment_index(state=PAYMENT_STATE_PAID),
+ )
+
+ def test_models_order_schedule_get_index_of_last_installment_state_refused(self):
+ """
+ Test that the method `get_installment_index` correctly returns the index of the
+ last installment with the state 'refused' in the payment schedule.
+ """
+ order = factories.OrderGeneratorFactory(
+ state=ORDER_STATE_PENDING_PAYMENT,
+ product__price=100,
+ )
+ order.payment_schedule[0]["state"] = PAYMENT_STATE_PAID
+ order.payment_schedule[1]["state"] = PAYMENT_STATE_REFUSED
+
+ self.assertEqual(
+ 1,
+ order.get_installment_index(state=PAYMENT_STATE_REFUSED),
+ )
+
+ def test_models_order_schedule_get_index_of_installment_pending_state_first_occurence(
+ self,
+ ):
+ """
+ Test that the method `get_installment_index` correctly returns the index of the
+ first installment with the state 'pending' in the payment schedule.
+ """
+ order = factories.OrderGeneratorFactory(
+ state=ORDER_STATE_PENDING_PAYMENT,
+ product__price=100,
+ )
+ order.payment_schedule[0]["state"] = PAYMENT_STATE_PAID
+ order.payment_schedule[1]["state"] = PAYMENT_STATE_PAID
+
+ self.assertEqual(
+ 2,
+ order.get_installment_index(state=PAYMENT_STATE_PENDING, find_first=True),
+ )
+
+ def test_models_order_schedule_get_index_of_installment_pending_state_last_occurence(
+ self,
+ ):
+ """
+ The method `get_installment_index` should return the last occurence in the
+ payment schedule depending the value set of
+ `find_first` parameter and also the `state` of the installment payment.
+ """
+ order = factories.OrderGeneratorFactory(
+ state=ORDER_STATE_PENDING_PAYMENT,
+ product__price=10,
+ )
+ order.payment_schedule[0]["state"] = PAYMENT_STATE_PAID
+
+ self.assertEqual(order.get_installment_index(PAYMENT_STATE_PENDING), 2)
+
+ def test_models_order_get_index_of_installment_should_return_none_because_no_refused_state(
+ self,
+ ):
+ """
+ The method `get_installment_index` should return None if there is no 'refused' payment
+ state present in the payment schedule.
+ """
+ order = factories.OrderGeneratorFactory(
+ state=ORDER_STATE_PENDING_PAYMENT,
+ product__price=5,
+ )
+ order.payment_schedule[0]["state"] = PAYMENT_STATE_PAID
+
+ self.assertIsNone(order.get_installment_index(PAYMENT_STATE_REFUSED))
+
+ def test_models_order_get_index_of_installment_should_return_none_because_no_paid_state(
+ self,
+ ):
+ """
+ The method `get_installment_index` should return None if there is no 'paid' payment
+ state present in the payment schedule.
+ """
+ order = factories.OrderGeneratorFactory(
+ state=ORDER_STATE_PENDING_PAYMENT,
+ product__price=5,
+ )
+ order.payment_schedule[0]["state"] = PAYMENT_STATE_PENDING
+ order.payment_schedule[0]["state"] = PAYMENT_STATE_PENDING
+
+ self.assertIsNone(order.get_installment_index(PAYMENT_STATE_PAID))
+
+ def test_models_order_get_index_of_installment_should_return_none_because_no_pending_state(
+ self,
+ ):
+ """
+ The method `get_installment_index` should return None if there is no 'pending' payment
+ state present in the payment schedule.
+ """
+ order = factories.OrderGeneratorFactory(
+ state=ORDER_STATE_PENDING_PAYMENT,
+ product__price=5,
+ )
+ order.payment_schedule[0]["state"] = PAYMENT_STATE_PAID
+ order.payment_schedule[1]["state"] = PAYMENT_STATE_REFUSED
+
+ self.assertIsNone(order.get_installment_index(PAYMENT_STATE_PENDING))
diff --git a/src/backend/joanie/tests/core/tasks/test_payment_schedule.py b/src/backend/joanie/tests/core/tasks/test_payment_schedule.py
index d84db7605..7168f502d 100644
--- a/src/backend/joanie/tests/core/tasks/test_payment_schedule.py
+++ b/src/backend/joanie/tests/core/tasks/test_payment_schedule.py
@@ -2,22 +2,43 @@
Test suite for payment schedule tasks
"""
-from datetime import datetime
+import json
+from datetime import date, datetime
+from decimal import Decimal as D
+from logging import Logger
from unittest import mock
from zoneinfo import ZoneInfo
+from django.core import mail
+from django.core.management import call_command
from django.test import TestCase
+from django.test.utils import override_settings
+from django.urls import reverse
+
+from rest_framework.test import APIRequestFactory
+from stockholm import Money
from joanie.core.enums import (
- ORDER_STATE_NO_PAYMENT,
ORDER_STATE_PENDING,
+ ORDER_STATE_PENDING_PAYMENT,
+ ORDER_STATE_TO_SAVE_PAYMENT_METHOD,
+ PAYMENT_STATE_PAID,
PAYMENT_STATE_PENDING,
PAYMENT_STATE_REFUSED,
)
-from joanie.core.factories import OrderFactory
-from joanie.core.tasks.payment_schedule import process_today_installment
+from joanie.core.factories import (
+ OrderFactory,
+ OrderGeneratorFactory,
+ UserAddressFactory,
+ UserFactory,
+)
+from joanie.core.tasks.payment_schedule import (
+ debit_pending_installment,
+ send_mail_reminder_installment_debit_task,
+)
+from joanie.payment import get_payment_backend
from joanie.payment.backends.dummy import DummyPaymentBackend
-from joanie.payment.factories import CreditCardFactory
+from joanie.payment.factories import InvoiceFactory
from joanie.tests.base import BaseLogMixinTestCase
@@ -33,15 +54,22 @@ class PaymentScheduleTasksTestCase(TestCase, BaseLogMixinTestCase):
"create_zero_click_payment",
side_effect=DummyPaymentBackend().create_zero_click_payment,
)
- def test_utils_payment_schedule_process_today_installment_succeeded(
+ def test_utils_payment_schedule_debit_pending_installment_succeeded(
self, mock_create_zero_click_payment
):
"""Check today's installment is processed"""
- credit_card = CreditCardFactory()
+ owner = UserFactory(
+ email="john.doe@acme.org",
+ first_name="John",
+ last_name="Doe",
+ language="en-us",
+ )
+ UserAddressFactory(owner=owner)
order = OrderFactory(
id="6134df5e-a7eb-4cb3-aceb-d0abfe330af6",
+ owner=owner,
state=ORDER_STATE_PENDING,
- owner=credit_card.owner,
+ main_invoice=InvoiceFactory(),
payment_schedule=[
{
"id": "d9356dd7-19a6-4695-b18e-ad93af41424a",
@@ -72,23 +100,24 @@ def test_utils_payment_schedule_process_today_installment_succeeded(
mocked_now = datetime(2024, 1, 17, 0, 0, tzinfo=ZoneInfo("UTC"))
with mock.patch("django.utils.timezone.now", return_value=mocked_now):
- process_today_installment.run(order.id)
+ debit_pending_installment.run(order.id)
mock_create_zero_click_payment.assert_called_once_with(
order=order,
- credit_card_token=credit_card.token,
+ credit_card_token=order.credit_card.token,
installment={
"id": "d9356dd7-19a6-4695-b18e-ad93af41424a",
- "amount": "200.00",
- "due_date": "2024-01-17",
+ "amount": Money("200.00"),
+ "due_date": date(2024, 1, 17),
"state": PAYMENT_STATE_PENDING,
},
)
- def test_utils_payment_schedule_process_today_installment_no_card(self):
+ def test_utils_payment_schedule_debit_pending_installment_no_card(self):
"""Check today's installment is processed"""
order = OrderFactory(
state=ORDER_STATE_PENDING,
+ credit_card=None,
payment_schedule=[
{
"id": "d9356dd7-19a6-4695-b18e-ad93af41424a",
@@ -119,17 +148,74 @@ def test_utils_payment_schedule_process_today_installment_no_card(self):
mocked_now = datetime(2024, 1, 17, 0, 0, tzinfo=ZoneInfo("UTC"))
with mock.patch("django.utils.timezone.now", return_value=mocked_now):
- process_today_installment.run(order.id)
+ debit_pending_installment.run(order.id)
order.refresh_from_db()
self.assertEqual(
order.payment_schedule,
[
+ {
+ "id": "d9356dd7-19a6-4695-b18e-ad93af41424a",
+ "amount": Money("200.00"),
+ "due_date": date(2024, 1, 17),
+ "state": PAYMENT_STATE_REFUSED,
+ },
+ {
+ "id": "1932fbc5-d971-48aa-8fee-6d637c3154a5",
+ "amount": Money("300.00"),
+ "due_date": date(2024, 2, 17),
+ "state": PAYMENT_STATE_PENDING,
+ },
+ {
+ "id": "168d7e8c-a1a9-4d70-9667-853bf79e502c",
+ "amount": Money("300.00"),
+ "due_date": date(2024, 3, 17),
+ "state": PAYMENT_STATE_PENDING,
+ },
+ {
+ "id": "9fcff723-7be4-4b77-87c6-2865e000f879",
+ "amount": Money("199.99"),
+ "due_date": date(2024, 4, 17),
+ "state": PAYMENT_STATE_PENDING,
+ },
+ ],
+ )
+ self.assertEqual(order.state, ORDER_STATE_TO_SAVE_PAYMENT_METHOD)
+
+ @mock.patch.object(Logger, "info")
+ @mock.patch.object(
+ DummyPaymentBackend,
+ "handle_notification",
+ side_effect=DummyPaymentBackend().handle_notification,
+ )
+ @mock.patch.object(
+ DummyPaymentBackend,
+ "create_zero_click_payment",
+ side_effect=DummyPaymentBackend().create_zero_click_payment,
+ )
+ def test_utils_payment_schedule_should_catch_up_late_payments_for_installments_still_unpaid(
+ self, mock_create_zero_click_payment, mock_handle_notification, mock_logger
+ ):
+ """
+ When the due date has come, we should verify that there are no missed previous
+ installments that were not paid, which still require a payment. In the case where a
+ previous installment is found that was not paid, we want our task to handle it and
+ trigger the payment with the method `create_zero_click_payment`. We then verify that
+ the method `handle_notification` updates the order's payment schedule for the installments
+ that were paid.
+ """
+ owner = UserFactory(email="john.doe@acme.org")
+ UserAddressFactory(owner=owner)
+ order = OrderFactory(
+ state=ORDER_STATE_PENDING,
+ owner=owner,
+ main_invoice=InvoiceFactory(),
+ payment_schedule=[
{
"id": "d9356dd7-19a6-4695-b18e-ad93af41424a",
"amount": "200.00",
"due_date": "2024-01-17",
- "state": PAYMENT_STATE_REFUSED,
+ "state": PAYMENT_STATE_PAID,
},
{
"id": "1932fbc5-d971-48aa-8fee-6d637c3154a5",
@@ -151,4 +237,215 @@ def test_utils_payment_schedule_process_today_installment_no_card(self):
},
],
)
- self.assertEqual(order.state, ORDER_STATE_NO_PAYMENT)
+
+ expected_calls = [
+ mock.call(
+ order=order,
+ credit_card_token=order.credit_card.token,
+ installment={
+ "id": "1932fbc5-d971-48aa-8fee-6d637c3154a5",
+ "amount": Money("300.00"),
+ "due_date": date(2024, 2, 17),
+ "state": PAYMENT_STATE_PENDING,
+ },
+ ),
+ mock.call(
+ order=order,
+ credit_card_token=order.credit_card.token,
+ installment={
+ "id": "168d7e8c-a1a9-4d70-9667-853bf79e502c",
+ "amount": Money("300.00"),
+ "due_date": date(2024, 3, 17),
+ "state": PAYMENT_STATE_PENDING,
+ },
+ ),
+ ]
+
+ mocked_now = datetime(2024, 3, 17, 0, 0, tzinfo=ZoneInfo("UTC"))
+ with mock.patch("django.utils.timezone.now", return_value=mocked_now):
+ debit_pending_installment.run(order.id)
+
+ mock_create_zero_click_payment.assert_has_calls(expected_calls, any_order=False)
+
+ backend = get_payment_backend()
+ first_request = APIRequestFactory().post(
+ reverse("payment_webhook"),
+ data={
+ "id": "pay_1932fbc5-d971-48aa-8fee-6d637c3154a5",
+ "type": "payment",
+ "state": "success",
+ },
+ format="json",
+ )
+ first_request.data = json.loads(first_request.body.decode("utf-8"))
+ backend.handle_notification(first_request)
+
+ mock_handle_notification.assert_called_with(first_request)
+ mock_logger.assert_called_with(
+ "Mail is sent to %s from dummy payment", "john.doe@acme.org"
+ )
+ mock_logger.reset_mock()
+
+ second_request = APIRequestFactory().post(
+ reverse("payment_webhook"),
+ data={
+ "id": "pay_168d7e8c-a1a9-4d70-9667-853bf79e502c",
+ "type": "payment",
+ "state": "success",
+ },
+ format="json",
+ )
+ second_request.data = json.loads(second_request.body.decode("utf-8"))
+ backend.handle_notification(second_request)
+
+ mock_handle_notification.assert_called_with(second_request)
+ mock_logger.assert_called_with(
+ "Mail is sent to %s from dummy payment", "john.doe@acme.org"
+ )
+
+ order.refresh_from_db()
+ self.assertEqual(
+ order.payment_schedule,
+ [
+ {
+ "id": "d9356dd7-19a6-4695-b18e-ad93af41424a",
+ "amount": Money("200.00"),
+ "due_date": date(2024, 1, 17),
+ "state": PAYMENT_STATE_PAID,
+ },
+ {
+ "id": "1932fbc5-d971-48aa-8fee-6d637c3154a5",
+ "amount": Money("300.00"),
+ "due_date": date(2024, 2, 17),
+ "state": PAYMENT_STATE_PAID,
+ },
+ {
+ "id": "168d7e8c-a1a9-4d70-9667-853bf79e502c",
+ "amount": Money("300.00"),
+ "due_date": date(2024, 3, 17),
+ "state": PAYMENT_STATE_PAID,
+ },
+ {
+ "id": "9fcff723-7be4-4b77-87c6-2865e000f879",
+ "amount": Money("199.99"),
+ "due_date": date(2024, 4, 17),
+ "state": PAYMENT_STATE_PENDING,
+ },
+ ],
+ )
+
+ @override_settings(
+ JOANIE_PAYMENT_SCHEDULE_LIMITS={
+ 5: (30, 70),
+ },
+ JOANIE_INSTALLMENT_REMINDER_PERIOD_DAYS=2,
+ DEFAULT_CURRENCY="EUR",
+ )
+ def test_payment_scheduled_send_mail_reminder_installment_debit_task_full_cycle(
+ self,
+ ):
+ """
+ According to the value configured in the setting `JOANIE_INSTALLMENT_REMINDER_PERIOD_DAYS`,
+ that is 2 days for this test, the command should find the orders that must be treated
+ and calls the method responsible to send the reminder email to the owner orders.
+ """
+ owner_1 = UserFactory(
+ first_name="John",
+ last_name="Doe",
+ email="john.doe@acme.org",
+ language="fr-fr",
+ )
+ UserAddressFactory(owner=owner_1)
+ owner_2 = UserFactory(
+ first_name="Sam", last_name="Doe", email="sam@fun-test.fr", language="fr-fr"
+ )
+ UserAddressFactory(owner=owner_2)
+ order_1 = OrderGeneratorFactory(
+ owner=owner_1,
+ state=ORDER_STATE_PENDING_PAYMENT,
+ product__price=D("5"),
+ product__title="Product 1",
+ )
+ order_1.payment_schedule[0]["state"] = PAYMENT_STATE_PAID
+ order_1.payment_schedule[0]["due_date"] = date(2024, 1, 17)
+ order_1.payment_schedule[1]["id"] = "1932fbc5-d971-48aa-8fee-6d637c3154a5"
+ order_1.payment_schedule[1]["due_date"] = date(2024, 2, 17)
+ order_1.payment_schedule[1]["state"] = PAYMENT_STATE_PENDING
+ order_1.save()
+ order_2 = OrderGeneratorFactory(
+ owner=owner_2,
+ state=ORDER_STATE_PENDING_PAYMENT,
+ product__price=D("5"),
+ product__title="Product 2",
+ )
+ order_2.payment_schedule[0]["state"] = PAYMENT_STATE_PAID
+ order_2.payment_schedule[1]["id"] = "a1cf9f39-594f-4528-a657-a0b9018b90ad"
+ order_2.payment_schedule[1]["due_date"] = date(2024, 2, 17)
+ order_2.save()
+ # This order should be ignored by the django command `send_mail_upcoming_debit`
+ order_3 = OrderGeneratorFactory(
+ state=ORDER_STATE_PENDING_PAYMENT,
+ product__price=D("5"),
+ product__title="Product 2",
+ )
+ order_3.payment_schedule[0]["state"] = PAYMENT_STATE_PAID
+ order_3.payment_schedule[1]["due_date"] = date(2024, 2, 18)
+ order_3.save()
+
+ # Orders that should be found with their installment that will be debited soon
+ expected_calls = [
+ mock.call.delay(
+ order_id=order_2.id,
+ installment_id="a1cf9f39-594f-4528-a657-a0b9018b90ad",
+ ),
+ mock.call.delay(
+ order_id=order_1.id,
+ installment_id="1932fbc5-d971-48aa-8fee-6d637c3154a5",
+ ),
+ ]
+
+ with (
+ mock.patch(
+ "django.utils.timezone.localdate", return_value=date(2024, 2, 15)
+ ),
+ mock.patch(
+ "joanie.core.tasks.payment_schedule.send_mail_reminder_installment_debit_task"
+ ) as mock_send_mail_reminder_installment_debit_task,
+ ):
+ call_command("send_mail_upcoming_debit")
+
+ mock_send_mail_reminder_installment_debit_task.assert_has_calls(
+ expected_calls, any_order=False
+ )
+
+ # Trigger now the task `send_mail_reminder_installment_debit_task` for order_1
+ send_mail_reminder_installment_debit_task.run(
+ order_id=order_1.id, installment_id=order_1.payment_schedule[1]["id"]
+ )
+
+ # Check if mail was sent to owner_1 about next upcoming debit
+ self.assertEqual(mail.outbox[0].to[0], "john.doe@acme.org")
+ self.assertIn("will be debited in 2 days.", mail.outbox[0].subject)
+ email_content_1 = " ".join(mail.outbox[0].body.split())
+ fullname_1 = order_1.owner.get_full_name()
+ self.assertIn(f"Hello {fullname_1}", email_content_1)
+ self.assertIn("installment will be withdrawn on 2 days", email_content_1)
+ self.assertIn("We will try to debit an amount of", email_content_1)
+ self.assertIn("3,5", email_content_1)
+ self.assertIn("Product 1", email_content_1)
+
+ # Trigger now the task `send_mail_reminder_installment_debit_task` for order_2
+ send_mail_reminder_installment_debit_task.run(
+ order_id=order_2.id, installment_id=order_2.payment_schedule[1]["id"]
+ )
+
+ # Check if mail was sent to owner_2 about next upcoming debit
+ self.assertEqual(mail.outbox[1].to[0], "sam@fun-test.fr")
+ self.assertIn("will be debited in 2 days.", mail.outbox[1].subject)
+ fullname_2 = order_2.owner.get_full_name()
+ email_content_2 = " ".join(mail.outbox[1].body.split())
+ self.assertIn(f"Hello {fullname_2}", email_content_2)
+ self.assertIn("installment will be withdrawn on 2 days", email_content_2)
+ self.assertIn("We will try to debit an amount of", email_content_2)
+ self.assertIn("1,5", email_content_2)
+ self.assertIn("Product 2", email_content_2)
diff --git a/src/backend/joanie/tests/core/test_api_admin_orders.py b/src/backend/joanie/tests/core/test_api_admin_orders.py
index b3e76e74a..90fa8cb36 100644
--- a/src/backend/joanie/tests/core/test_api_admin_orders.py
+++ b/src/backend/joanie/tests/core/test_api_admin_orders.py
@@ -1,6 +1,5 @@
"""Test suite for the admin orders API endpoints."""
-import random
import uuid
from datetime import timedelta
from decimal import Decimal as D
@@ -528,12 +527,12 @@ def test_api_admin_orders_course_retrieve(self):
product__certificate_definition=factories.CertificateDefinitionFactory(),
)
order_group = factories.OrderGroupFactory(course_product_relation=relation)
- order = factories.OrderFactory(
+ order = factories.OrderGeneratorFactory(
course=relation.course,
product=relation.product,
order_group=order_group,
organization=relation.organizations.first(),
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
# Create certificate
@@ -541,20 +540,13 @@ def test_api_admin_orders_course_retrieve(self):
order=order, certificate_definition=order.product.certificate_definition
)
- # Create signed contract
- factories.ContractFactory(
- order=order,
- student_signed_on=order.created_on,
- organization_signed_on=order.created_on,
- )
-
# Create a credit note
credit_note = InvoiceFactory(
parent=order.main_invoice,
total=D("1.00"),
)
- with self.assertNumQueries(29):
+ with self.assertNumQueries(27):
response = self.client.get(f"/api/v1.0/admin/orders/{order.id}/")
self.assertEqual(response.status_code, HTTPStatus.OK)
@@ -564,7 +556,6 @@ def test_api_admin_orders_course_retrieve(self):
"id": str(order.id),
"created_on": format_date(order.created_on),
"state": order.state,
- "has_consent_to_terms": False,
"owner": {
"id": str(order.owner.id),
"username": order.owner.username,
@@ -581,7 +572,7 @@ def test_api_admin_orders_course_retrieve(self):
"id": str(relation.product.id),
"price": float(relation.product.price),
"price_currency": "EUR",
- "target_courses": [],
+ "target_courses": [str(order.course.id)],
"title": relation.product.title,
"type": "credential",
},
@@ -618,7 +609,7 @@ def test_api_admin_orders_course_retrieve(self):
"definition_title": order.contract.definition.title,
"student_signed_on": format_date(order.contract.student_signed_on),
"organization_signed_on": format_date(
- order.contract.student_signed_on
+ order.contract.organization_signed_on
),
"submitted_for_signature_on": None,
},
@@ -627,6 +618,16 @@ def test_api_admin_orders_course_retrieve(self):
"definition_title": order.certificate.certificate_definition.title,
"issued_on": format_date(order.certificate.issued_on),
},
+ "payment_schedule": [
+ {
+ "id": str(installment["id"]),
+ "amount": float(installment["amount"]),
+ "currency": "EUR",
+ "due_date": format_date(installment["due_date"]),
+ "state": installment["state"],
+ }
+ for installment in order.payment_schedule
+ ],
"main_invoice": {
"id": str(order.main_invoice.id),
"balance": float(order.main_invoice.balance),
@@ -667,6 +668,13 @@ def test_api_admin_orders_course_retrieve(self):
"type": order.main_invoice.type,
"updated_on": format_date(order.main_invoice.updated_on),
},
+ "credit_card": {
+ "id": str(order.credit_card.id),
+ "last_numbers": order.credit_card.last_numbers,
+ "brand": order.credit_card.brand,
+ "expiration_month": order.credit_card.expiration_month,
+ "expiration_year": order.credit_card.expiration_year,
+ },
},
)
@@ -692,7 +700,7 @@ def test_api_admin_orders_enrollment_retrieve(self):
course=None,
product=relation.product,
organization=relation.organizations.first(),
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
# Create certificate
@@ -710,7 +718,6 @@ def test_api_admin_orders_enrollment_retrieve(self):
"id": str(order.id),
"created_on": format_date(order.created_on),
"state": order.state,
- "has_consent_to_terms": False,
"owner": {
"id": str(order.owner.id),
"username": order.owner.username,
@@ -781,6 +788,7 @@ def test_api_admin_orders_enrollment_retrieve(self):
"definition_title": order.certificate.certificate_definition.title,
"issued_on": format_date(order.certificate.issued_on),
},
+ "payment_schedule": None,
"main_invoice": {
"id": str(order.main_invoice.id),
"balance": float(order.main_invoice.balance),
@@ -801,6 +809,13 @@ def test_api_admin_orders_enrollment_retrieve(self):
"type": order.main_invoice.type,
"updated_on": format_date(order.main_invoice.updated_on),
},
+ "credit_card": {
+ "id": str(order.credit_card.id),
+ "last_numbers": order.credit_card.last_numbers,
+ "brand": order.credit_card.brand,
+ "expiration_month": order.credit_card.expiration_month,
+ "expiration_year": order.credit_card.expiration_year,
+ },
},
)
@@ -852,21 +867,11 @@ def test_api_admin_orders_delete(self):
def test_api_admin_orders_cancel_anonymous(self):
"""An anonymous user cannot cancel an order."""
- order = factories.OrderFactory(
- state=random.choice(
- [
- enums.ORDER_STATE_CANCELED,
- enums.ORDER_STATE_SUBMITTED,
- enums.ORDER_STATE_DRAFT,
- enums.ORDER_STATE_PENDING,
- enums.ORDER_STATE_VALIDATED,
- ]
- )
- )
-
- response = self.client.delete(f"/api/v1.0/admin/orders/{order.id}/")
-
- self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED)
+ for state, _ in enums.ORDER_STATE_CHOICES:
+ with self.subTest(state=state):
+ order = factories.OrderFactory(state=state)
+ response = self.client.delete(f"/api/v1.0/admin/orders/{order.id}/")
+ self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED)
def test_api_admin_orders_cancel_authenticated_with_lambda_user(self):
"""
@@ -874,7 +879,7 @@ def test_api_admin_orders_cancel_authenticated_with_lambda_user(self):
"""
admin = factories.UserFactory(is_staff=False, is_superuser=False)
self.client.login(username=admin.username, password="password")
- order = factories.OrderFactory(state=enums.ORDER_STATE_SUBMITTED)
+ order = factories.OrderFactory(state=enums.ORDER_STATE_PENDING)
response = self.client.delete(f"/api/v1.0/admin/orders/{order.id}/")
@@ -898,42 +903,13 @@ def test_api_admin_orders_cancel_authenticated(self):
admin = factories.UserFactory(is_staff=True, is_superuser=True)
self.client.login(username=admin.username, password="password")
- order_is_draft = factories.OrderFactory(state=enums.ORDER_STATE_DRAFT)
- order_is_pending = factories.OrderFactory(state=enums.ORDER_STATE_PENDING)
- order_is_submitted = factories.OrderFactory(state=enums.ORDER_STATE_SUBMITTED)
- order_is_validated = factories.OrderFactory(state=enums.ORDER_STATE_VALIDATED)
-
- # Canceling draft order
- response = self.client.delete(
- f"/api/v1.0/admin/orders/{order_is_draft.id}/",
- )
- order_is_draft.refresh_from_db()
- self.assertEqual(response.status_code, HTTPStatus.NO_CONTENT)
- self.assertEqual(order_is_draft.state, enums.ORDER_STATE_CANCELED)
-
- # Canceling pending order
- response = self.client.delete(
- f"/api/v1.0/admin/orders/{order_is_pending.id}/",
- )
- order_is_pending.refresh_from_db()
- self.assertEqual(response.status_code, HTTPStatus.NO_CONTENT)
- self.assertEqual(order_is_pending.state, enums.ORDER_STATE_CANCELED)
-
- # Canceling submitted order
- response = self.client.delete(
- f"/api/v1.0/admin/orders/{order_is_submitted.id}/",
- )
- order_is_submitted.refresh_from_db()
- self.assertEqual(response.status_code, HTTPStatus.NO_CONTENT)
- self.assertEqual(order_is_submitted.state, enums.ORDER_STATE_CANCELED)
-
- # Canceling validated order
- response = self.client.delete(
- f"/api/v1.0/admin/orders/{order_is_validated.id}/",
- )
- order_is_validated.refresh_from_db()
- self.assertEqual(response.status_code, HTTPStatus.NO_CONTENT)
- self.assertEqual(order_is_validated.state, enums.ORDER_STATE_CANCELED)
+ for state, _ in enums.ORDER_STATE_CHOICES:
+ with self.subTest(state=state):
+ order = factories.OrderFactory(state=state)
+ response = self.client.delete(f"/api/v1.0/admin/orders/{order.id}/")
+ order.refresh_from_db()
+ self.assertEqual(response.status_code, HTTPStatus.NO_CONTENT)
+ self.assertEqual(order.state, enums.ORDER_STATE_CANCELED)
def test_api_admin_orders_generate_certificate_anonymous_user(self):
"""
@@ -1102,7 +1078,7 @@ def test_api_admin_orders_generate_certificate_authenticated_when_product_type_i
type=enums.PRODUCT_TYPE_ENROLLMENT,
target_courses=[course_run.course],
),
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
response = self.client.post(
@@ -1157,7 +1133,7 @@ def test_api_admin_orders_generate_certificate_authenticated_for_certificate_pro
product=product,
course=None,
enrollment=enrollment,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
# Simulate that enrollment is not passed
@@ -1252,7 +1228,7 @@ def test_api_admin_orders_generate_certificate_authenticated_for_credential_prod
order = factories.OrderFactory(
product=product,
)
- order.submit()
+ order.init_flow()
enrollment = Enrollment.objects.get(course_run=course_run_1)
# Simulate that all enrollments for graded courses made by the order are not passed
@@ -1334,7 +1310,7 @@ def test_api_admin_orders_generate_certificate_was_already_generated_type_certif
product=product,
course=None,
enrollment=enrollment,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
# Simulate that enrollment is passed
@@ -1404,7 +1380,7 @@ def test_api_admin_orders_generate_certificate_was_already_generated_type_creden
is_graded=True,
)
order = factories.OrderFactory(product=product)
- order.submit()
+ order.init_flow()
self.assertFalse(Certificate.objects.exists())
@@ -1474,7 +1450,6 @@ def test_api_admin_orders_generate_certificate_when_no_graded_courses_from_order
is_graded=False, # grades are not yet enabled on this course
)
order = factories.OrderFactory(product=product)
- order.submit()
response = self.client.post(
f"/api/v1.0/admin/orders/{order.id}/generate_certificate/",
diff --git a/src/backend/joanie/tests/core/test_api_contract.py b/src/backend/joanie/tests/core/test_api_contract.py
index 3939b9c41..aa3bbd71b 100644
--- a/src/backend/joanie/tests/core/test_api_contract.py
+++ b/src/backend/joanie/tests/core/test_api_contract.py
@@ -17,6 +17,7 @@
from joanie.core.serializers import fields
from joanie.core.utils import contract as contract_utility
from joanie.core.utils import contract_definition
+from joanie.payment.factories import InvoiceFactory
from joanie.tests.base import BaseAPITestCase
# pylint: disable=too-many-lines,disable=duplicate-code
@@ -722,7 +723,7 @@ def test_api_contracts_retrieve_with_owner(self, _):
contract = factories.ContractFactory(
order__owner=user,
organization_signatory=organization_signatory,
- order__state=enums.ORDER_STATE_VALIDATED,
+ order__state=enums.ORDER_STATE_COMPLETED,
)
with self.assertNumQueries(7):
@@ -772,7 +773,7 @@ def test_api_contracts_retrieve_with_owner(self, _):
},
"order": {
"id": str(contract.order.id),
- "state": enums.ORDER_STATE_VALIDATED,
+ "state": contract.order.state,
"course": {
"code": contract.order.course.code,
"cover": "_this_field_is_mocked",
@@ -958,7 +959,7 @@ def test_api_contract_download_authenticated_with_validate_order_succeeds(self):
)
order = factories.OrderFactory(
owner=user,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
product__contract_definition=factories.ContractDefinitionFactory(),
)
address = order.main_invoice.recipient_address
@@ -1004,31 +1005,33 @@ def test_api_contract_download_authenticated_with_not_validate_order(self):
user = factories.UserFactory(
email="student_do@example.fr", first_name="John Doe", last_name=""
)
- order = factories.OrderFactory(
- owner=user,
- state=random.choice(
- [
- enums.ORDER_STATE_PENDING,
- enums.ORDER_STATE_DRAFT,
- enums.ORDER_STATE_CANCELED,
- enums.ORDER_STATE_SUBMITTED,
- ]
- ),
- product__contract_definition=factories.ContractDefinitionFactory(),
- )
- contract = factories.ContractFactory(order=order)
- token = self.get_user_token(user.username)
-
- response = self.client.get(
- f"/api/v1.0/contracts/{str(contract.id)}/download/",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
-
- self.assertContains(
- response,
- "No Contract matches the given query.",
- status_code=HTTPStatus.NOT_FOUND,
- )
+ for state, _ in enums.ORDER_STATE_CHOICES:
+ with self.subTest(state=state):
+ order = factories.OrderFactory(
+ owner=user,
+ state=state,
+ product__contract_definition=factories.ContractDefinitionFactory(),
+ )
+ contract = factories.ContractFactory(order=order)
+ token = self.get_user_token(user.username)
+
+ response = self.client.get(
+ f"/api/v1.0/contracts/{str(contract.id)}/download/",
+ HTTP_AUTHORIZATION=f"Bearer {token}",
+ )
+
+ if state == enums.ORDER_STATE_CANCELED:
+ self.assertContains(
+ response,
+ "No Contract matches the given query.",
+ status_code=HTTPStatus.NOT_FOUND,
+ )
+ else:
+ self.assertContains(
+ response,
+ "Cannot download a contract when it is not yet fully signed.",
+ status_code=HTTPStatus.BAD_REQUEST,
+ )
def test_api_contract_download_authenticated_cannot_create(self):
"""
@@ -1039,7 +1042,7 @@ def test_api_contract_download_authenticated_cannot_create(self):
)
order = factories.OrderFactory(
owner=user,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
product__contract_definition=factories.ContractDefinitionFactory(),
)
contract = factories.ContractFactory(order=order)
@@ -1065,7 +1068,7 @@ def test_api_contract_download_authenticated_cannot_update(self):
)
order = factories.OrderFactory(
owner=user,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
product__contract_definition=factories.ContractDefinitionFactory(),
)
contract = factories.ContractFactory(order=order)
@@ -1091,7 +1094,7 @@ def test_api_contract_download_authenticated_cannot_delete(self):
)
order = factories.OrderFactory(
owner=user,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
product__contract_definition=factories.ContractDefinitionFactory(),
)
contract = factories.ContractFactory(order=order)
@@ -1120,7 +1123,7 @@ def test_api_contract_download_authenticated_should_fail_if_owner_is_not_the_act
)
order = factories.OrderFactory(
owner=owner,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
product__contract_definition=factories.ContractDefinitionFactory(),
)
contract = factories.ContractFactory(
@@ -1159,7 +1162,7 @@ def test_api_contract_download_authenticated_should_fail_if_contract_is_not_sign
)
order = factories.OrderFactory(
owner=owner,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
product__contract_definition=factories.ContractDefinitionFactory(),
)
contract = factories.ContractFactory(
@@ -1363,6 +1366,7 @@ def test_api_contract_generate_zip_archive_authenticated_post_passing_organizati
# Create our Course Product Relation shared by the 2 organizations above
relation = factories.CourseProductRelationFactory(
product__contract_definition=factories.ContractDefinitionFactory(),
+ product__price=0,
organizations=[organizations[0], organizations[1]],
)
# Create learners who sign the contract definition
@@ -1373,8 +1377,15 @@ def test_api_contract_generate_zip_archive_authenticated_post_passing_organizati
owner=learners[index],
product=relation.product,
course=relation.course,
- state=enums.ORDER_STATE_VALIDATED,
organization=organizations[index],
+ main_invoice=InvoiceFactory(),
+ payment_schedule=[
+ {
+ "amount": "200.00",
+ "due_date": "2024-01-17",
+ "state": enums.PAYMENT_STATE_PAID,
+ }
+ ],
)
context = contract_definition.generate_document_context(
order.product.contract_definition, learners[index], order
@@ -1387,6 +1398,7 @@ def test_api_contract_generate_zip_archive_authenticated_post_passing_organizati
student_signed_on=timezone.now(),
organization_signed_on=timezone.now(),
)
+ order.init_flow()
# Create token for only one organization accessor
token = self.get_user_token(user.username)
@@ -1445,6 +1457,7 @@ def test_api_contract_generate_zip_archive_authenticated_post_method_allowed(sel
)
relation = factories.CourseProductRelationFactory(
product__contract_definition=factories.ContractDefinitionFactory(),
+ product__price=0,
organizations=[organization],
)
learners = factories.UserFactory.create_batch(3)
@@ -1457,7 +1470,14 @@ def test_api_contract_generate_zip_archive_authenticated_post_method_allowed(sel
owner=user,
product=relation.product,
course=relation.course,
- state=enums.ORDER_STATE_VALIDATED,
+ main_invoice=InvoiceFactory(),
+ payment_schedule=[
+ {
+ "amount": "200.00",
+ "due_date": "2024-01-17",
+ "state": enums.PAYMENT_STATE_PAID,
+ }
+ ],
)
context = contract_definition.generate_document_context(
order.product.contract_definition, user, order
@@ -1470,6 +1490,7 @@ def test_api_contract_generate_zip_archive_authenticated_post_method_allowed(sel
student_signed_on=timezone.now(),
organization_signed_on=timezone.now(),
)
+ order.init_flow()
expected_endpoint_polling = "/api/v1.0/contracts/zip-archive/"
token = self.get_user_token(requesting_user.username)
@@ -1876,7 +1897,7 @@ def test_api_contract_download_signed_file_authenticated_not_fully_signed_by_stu
)
order = factories.OrderFactory(
owner=user,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
product__contract_definition=factories.ContractDefinitionFactory(),
)
contract = factories.ContractFactory(
@@ -1914,7 +1935,7 @@ def test_api_contract_download_signed_file_authenticated_not_fully_signed_by_org
)
order = factories.OrderFactory(
owner=user,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
product__contract_definition=factories.ContractDefinitionFactory(),
)
contract = factories.ContractFactory(
diff --git a/src/backend/joanie/tests/core/test_api_contract_definitions.py b/src/backend/joanie/tests/core/test_api_contract_definitions.py
index 6803e9ff8..49713a716 100644
--- a/src/backend/joanie/tests/core/test_api_contract_definitions.py
+++ b/src/backend/joanie/tests/core/test_api_contract_definitions.py
@@ -5,8 +5,6 @@
from http import HTTPStatus
from io import BytesIO
-from django.contrib.sites.models import Site
-
from pdfminer.high_level import extract_text as pdf_extract_text
from joanie.core import factories
@@ -137,15 +135,13 @@ def test_api_contract_definition_preview_template_success(
user = factories.UserFactory(
email="student_do@example.fr", first_name="John Doe", last_name=""
)
- factories.SiteConfigFactory(
- site=Site.objects.get_current(),
- terms_and_conditions="""
+ factories.UserAddressFactory(owner=user)
+ contract_definition = factories.ContractDefinitionFactory(
+ body="""
## Terms and conditions
Here are the terms and conditions of the current contract
""",
)
- factories.UserAddressFactory(owner=user)
- contract_definition = factories.ContractDefinitionFactory()
token = self.get_user_token(user.username)
response = self.client.get(
diff --git a/src/backend/joanie/tests/core/test_api_course_product_relations.py b/src/backend/joanie/tests/core/test_api_course_product_relations.py
index 4dfcdada8..4ad685fde 100644
--- a/src/backend/joanie/tests/core/test_api_course_product_relations.py
+++ b/src/backend/joanie/tests/core/test_api_course_product_relations.py
@@ -761,16 +761,15 @@ def test_api_course_product_relation_read_detail_with_order_groups(self):
course_product_relation=relation, nb_seats=random.randint(10, 100)
)
order_group2 = factories.OrderGroupFactory(course_product_relation=relation)
- binding_states = ["pending", "submitted", "validated"]
for _ in range(3):
factories.OrderFactory(
course=course,
product=product,
order_group=order_group1,
- state=random.choice(binding_states),
+ state=random.choice(enums.ORDER_STATES_BINDING),
)
for state, _label in enums.ORDER_STATE_CHOICES:
- if state in binding_states:
+ if state in enums.ORDER_STATES_BINDING:
continue
factories.OrderFactory(
course=course, product=product, order_group=order_group1, state=state
@@ -844,9 +843,9 @@ def test_api_course_product_relation_read_detail_with_order_groups_cache(self):
],
)
- # Submitting order should impact the number of seat availabilities in the
+ # Starting order state flow should impact the number of seat availabilities in the
# representation of the product
- order.submit()
+ order.init_flow()
response = self.client.get(
f"/api/v1.0/course-product-relations/{relation.id}/",
@@ -1445,7 +1444,8 @@ def test_api_course_product_relation_payment_schedule_with_product_id_anonymous(
):
"""
Anonymous users should be able to retrieve a payment schedule for
- a single course product relation if a product id is provided.
+ a single course product relation if a product id is provided
+ and the product is a credential.
"""
course_run = factories.CourseRunFactory(
enrollment_start=datetime(2024, 1, 1, 14, tzinfo=ZoneInfo("UTC")),
@@ -1501,3 +1501,64 @@ def test_api_course_product_relation_payment_schedule_with_product_id_anonymous(
self.assertEqual(response_relation_path.status_code, HTTPStatus.OK)
self.assertEqual(response_relation_path.json(), response.json())
+
+ @override_settings(
+ JOANIE_PAYMENT_SCHEDULE_LIMITS={
+ 5: (100,),
+ },
+ DEFAULT_CURRENCY="EUR",
+ )
+ def test_api_course_product_relation_payment_schedule_with_certificate_product_id_anonymous(
+ self,
+ ):
+ """
+ Anonymous users should be able to retrieve a payment schedule for
+ a single course product relation if a product id is provided
+ and the product is a certificate.
+ """
+ course_run = factories.CourseRunFactory(
+ enrollment_start=datetime(2024, 1, 1, 14, tzinfo=ZoneInfo("UTC")),
+ start=datetime(2024, 3, 1, 14, tzinfo=ZoneInfo("UTC")),
+ end=datetime(2024, 5, 1, 14, tzinfo=ZoneInfo("UTC")),
+ )
+ product = factories.ProductFactory(
+ price=3,
+ type=enums.PRODUCT_TYPE_CERTIFICATE,
+ )
+ relation = factories.CourseProductRelationFactory(
+ course=course_run.course,
+ product=product,
+ organizations=factories.OrganizationFactory.create_batch(2),
+ )
+
+ with (
+ mock.patch("uuid.uuid4", return_value=uuid.UUID(int=1)),
+ mock.patch(
+ "django.utils.timezone.now",
+ return_value=datetime(2024, 1, 1, 14, tzinfo=ZoneInfo("UTC")),
+ ),
+ ):
+ response = self.client.get(
+ f"/api/v1.0/courses/{course_run.course.code}/"
+ f"products/{product.id}/payment-schedule/"
+ )
+ response_relation_path = self.client.get(
+ f"/api/v1.0/course-product-relations/{relation.id}/payment-schedule/"
+ )
+
+ self.assertEqual(response.status_code, HTTPStatus.OK)
+ self.assertEqual(
+ response.json(),
+ [
+ {
+ "id": "00000000-0000-0000-0000-000000000001",
+ "amount": 3.00,
+ "currency": settings.DEFAULT_CURRENCY,
+ "due_date": "2024-01-17",
+ "state": enums.PAYMENT_STATE_PENDING,
+ },
+ ],
+ )
+
+ self.assertEqual(response_relation_path.status_code, HTTPStatus.OK)
+ self.assertEqual(response_relation_path.json(), response.json())
diff --git a/src/backend/joanie/tests/core/test_api_courses_contract.py b/src/backend/joanie/tests/core/test_api_courses_contract.py
index 99ac0fdd1..4fc67f24a 100644
--- a/src/backend/joanie/tests/core/test_api_courses_contract.py
+++ b/src/backend/joanie/tests/core/test_api_courses_contract.py
@@ -117,7 +117,7 @@ def test_api_courses_contracts_list_with_accesses(self, _):
self.assertEqual(response.status_code, HTTPStatus.OK)
contracts = models.Contract.objects.filter(
order__course=courses[0],
- order__state=enums.ORDER_STATE_VALIDATED,
+ order__state=enums.ORDER_STATE_COMPLETED,
)
expected_contracts = sorted(contracts, key=lambda x: x.created_on, reverse=True)
assert response.json() == {
diff --git a/src/backend/joanie/tests/core/test_api_courses_order.py b/src/backend/joanie/tests/core/test_api_courses_order.py
index 495f03c00..23164c936 100644
--- a/src/backend/joanie/tests/core/test_api_courses_order.py
+++ b/src/backend/joanie/tests/core/test_api_courses_order.py
@@ -165,7 +165,7 @@ def test_api_courses_order_get_list_learners_when_filter_wrong_organization_quer
owner=user_learner,
product=relation.product,
course=relation.course,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
token = self.get_user_token(user.username)
@@ -269,28 +269,28 @@ def test_api_courses_order_get_list_learners_authenticated_user_without_query_pa
owner=user_learners[0],
product=product,
course=relation_1.course,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
factories.OrderFactory(
organization=organizations[1],
owner=user_learners[1],
product=product,
course=relation_2.course,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
factories.OrderFactory(
organization=organizations[1],
owner=user_learners[2],
product=product,
course=relation_2.course,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
factories.OrderFactory(
organization=organizations[2],
owner=user_learners[3],
product=product,
course=relation_3.course,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
factories.UserOrganizationAccessFactory(
organization=organizations[0], user=user
@@ -345,7 +345,7 @@ def test_api_courses_order_get_list_leaners_filter_by_existing_organization_quer
owner=user_learners[i],
product=relation.product,
course=relation.course,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
factories.ContractFactory(order=order)
factories.OrderCertificateFactory(order=order)
@@ -467,14 +467,14 @@ def test_api_courses_order_get_list_filtering_filter_by_product_when_product_is_
owner=user_learners[0],
product=product,
course=courses[0],
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
factories.OrderFactory(
organization=organization,
owner=user_learners[1],
product=product,
course=courses[0],
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
# Third order with the same product and another course
factories.OrderFactory(
@@ -482,7 +482,7 @@ def test_api_courses_order_get_list_filtering_filter_by_product_when_product_is_
owner=user_learners[2],
product=product,
course=courses[1],
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
factories.UserOrganizationAccessFactory(organization=organization, user=user)
token = self.get_user_token(user.username)
@@ -561,14 +561,14 @@ def test_api_courses_order_get_list_learners_filter_by_product_and_organization_
owner=user_learners[0],
product=product,
course=courses[0],
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
factories.OrderFactory(
organization=organizations[0],
owner=user_learners[1],
product=product,
course=courses[0],
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
# Make Order with product number 2 and with the common course
factories.OrderFactory(
@@ -576,7 +576,7 @@ def test_api_courses_order_get_list_learners_filter_by_product_and_organization_
owner=user_learners[2],
product=product,
course=courses[1],
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
factories.UserOrganizationAccessFactory(
organization=organizations[0], user=user
@@ -667,21 +667,21 @@ def test_api_courses_order_get_list_learners_filter_by_course_product_relation_i
owner=user_learners[0],
product=product,
course=relation_1.course,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
factories.OrderFactory(
organization=organizations[0],
owner=user_learners[1],
product=product,
course=relation_1.course,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
factories.OrderFactory(
organization=organizations[1],
owner=user_learners[2],
product=product,
course=relation_2.course,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
factories.UserOrganizationAccessFactory(
organization=organizations[0], user=user
@@ -770,21 +770,21 @@ def test_api_courses_order_get_list_with_course_id_not_related_to_course_product
owner=user_learners[0],
product=product,
course=relation_1.course,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
factories.OrderFactory(
organization=organizations[0],
owner=user_learners[1],
product=product,
course=relation_1.course,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
factories.OrderFactory(
organization=organizations[1],
owner=user_learners[2],
product=product,
course=relation_2.course,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
for organization in organizations:
factories.UserOrganizationAccessFactory(
@@ -833,7 +833,7 @@ def test_api_courses_order_get_list_must_have_organization_access_to_get_results
owner=user_learner,
product=product,
course=relation.course,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
token = self.get_user_token(user.username)
@@ -904,3 +904,41 @@ def test_api_courses_order_get_list_must_have_organization_access_to_get_results
response.json()["results"][0]["product"]["id"], str(product.id)
)
self.assertEqual(response.json()["results"][0]["course_id"], str(course.id))
+
+ def test_api_courses_order_get_list_filters_order_states(self):
+ """
+ Only orders with the states 'completed', 'pending_payment' and 'failed_payment' should
+ be returned.
+ """
+ for state, _ in enums.ORDER_STATE_CHOICES:
+ with self.subTest(state=state):
+ user = factories.UserFactory()
+ course_product_relation = factories.CourseProductRelationFactory()
+ organization = course_product_relation.organizations.first()
+ product = course_product_relation.product
+ course = course_product_relation.course
+ order = factories.OrderFactory(
+ organization=organization,
+ product=product,
+ course=course,
+ state=state,
+ )
+ factories.UserOrganizationAccessFactory(
+ organization=organization, user=user
+ )
+ token = self.get_user_token(user.username)
+
+ response = self.client.get(
+ f"/api/v1.0/courses/{course.id}/orders/"
+ f"?product_id={product.id}",
+ HTTP_AUTHORIZATION=f"Bearer {token}",
+ )
+
+ self.assertEqual(response.status_code, HTTPStatus.OK)
+ if state in enums.ORDER_STATES_BINDING:
+ self.assertEqual(response.json()["count"], 1)
+ self.assertEqual(
+ response.json().get("results")[0].get("id"), str(order.id)
+ )
+ else:
+ self.assertEqual(response.json()["count"], 0)
diff --git a/src/backend/joanie/tests/core/test_api_enrollment.py b/src/backend/joanie/tests/core/test_api_enrollment.py
index 4e38cc7c2..938df19d5 100644
--- a/src/backend/joanie/tests/core/test_api_enrollment.py
+++ b/src/backend/joanie/tests/core/test_api_enrollment.py
@@ -9,6 +9,7 @@
from logging import Logger
from unittest import mock
+from django.conf import settings
from django.test.utils import override_settings
from django.utils import timezone
@@ -18,6 +19,7 @@
from joanie.core.serializers import fields
from joanie.lms_handler.backends.openedx import OpenEdXLMSBackend
from joanie.payment.factories import InvoiceFactory
+from joanie.tests import format_date
from joanie.tests.base import BaseAPITestCase
@@ -682,7 +684,7 @@ def test_api_enrollment_read_detail_authenticated_owner_success(self, *_):
factories.OrderFactory(
owner=user,
product__target_courses=target_courses,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
enrollment = factories.EnrollmentFactory(
@@ -761,13 +763,17 @@ def test_api_enrollment_read_detail_authenticated_owner_certificate(self):
order = factories.OrderFactory(
product=product, enrollment=enrollment, course=None
)
+ # Generate payment schedule
+ order.generate_schedule()
certificate = factories.OrderCertificateFactory(order=order)
token = self.generate_token_from_user(order.owner)
- response = self.client.get(
- f"/api/v1.0/enrollments/{enrollment.id}/",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
+ with self.assertNumQueries(32):
+ response = self.client.get(
+ f"/api/v1.0/enrollments/{enrollment.id}/",
+ HTTP_AUTHORIZATION=f"Bearer {token}",
+ )
+
self.assertEqual(response.status_code, HTTPStatus.OK)
content = response.json()
@@ -779,6 +785,18 @@ def test_api_enrollment_read_detail_authenticated_owner_certificate(self):
"certificate_id": str(certificate.id),
"product_id": str(product.id),
"state": "draft",
+ "payment_schedule": [
+ {
+ "id": str(installment["id"]),
+ "amount": float(installment["amount"]),
+ "currency": settings.DEFAULT_CURRENCY,
+ "due_date": format_date(installment["due_date"]),
+ "state": installment["state"],
+ }
+ for installment in order.payment_schedule
+ ]
+ if order.payment_schedule
+ else None,
}
],
)
@@ -951,7 +969,7 @@ def test_api_enrollment_duplicate_course_run_with_order(self, _mock_set):
)
product = factories.ProductFactory(target_courses=[target_course], price="0.00")
order = factories.OrderFactory(owner=user, product=product)
- order.submit()
+ order.init_flow()
# Create a pre-existing enrollment and try to enroll to this course's second course run
factories.EnrollmentFactory(
@@ -1250,36 +1268,34 @@ def test_api_enrollment_create_authenticated_matching_unvalidated_order(self):
product = factories.ProductFactory(
target_courses=[cr.course for cr in target_course_runs]
)
- order = factories.OrderFactory(
- product=product,
- state=random.choice(
- [enums.ORDER_STATE_PENDING, enums.ORDER_STATE_CANCELED]
- ),
- )
- data = {
- "course_run_id": target_course_runs[0].id,
- "is_active": True,
- "was_created_by_order": True,
- }
- token = self.generate_token_from_user(order.owner)
-
- response = self.client.post(
- "/api/v1.0/enrollments/",
- data=data,
- content_type="application/json",
- HTTP_AUTHORIZATION=f"Bearer {token}",
- )
- self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)
- course_run_id = target_course_runs[0].id
- self.assertDictEqual(
- response.json(),
- {
- "__all__": [
- f'Course run "{course_run_id}" requires a valid order to enroll.'
- ]
- },
- )
+ for state in [enums.ORDER_STATE_PENDING, enums.ORDER_STATE_CANCELED]:
+ with self.subTest(state=state):
+ order = factories.OrderFactory(product=product, state=state)
+ data = {
+ "course_run_id": target_course_runs[0].id,
+ "is_active": True,
+ "was_created_by_order": True,
+ }
+ token = self.generate_token_from_user(order.owner)
+
+ response = self.client.post(
+ "/api/v1.0/enrollments/",
+ data=data,
+ content_type="application/json",
+ HTTP_AUTHORIZATION=f"Bearer {token}",
+ )
+ self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)
+
+ course_run_id = target_course_runs[0].id
+ self.assertDictEqual(
+ response.json(),
+ {
+ "__all__": [
+ f'Course run "{course_run_id}" requires a valid order to enroll.'
+ ]
+ },
+ )
def test_api_enrollment_create_authenticated_matching_no_order(self):
"""
@@ -1732,7 +1748,7 @@ def _check_api_enrollment_update_detail(self, enrollment, user, http_code):
factories.OrderFactory(
owner=other_user,
product=enrollment.course_run.course.targeted_by_products.first(),
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
# Try modifying the enrollment on each field with our alternative data
@@ -1787,7 +1803,7 @@ def test_api_enrollment_update_detail_anonymous(self, _mock_set):
)
product = factories.ProductFactory(target_courses=[target_course])
order = factories.OrderFactory(
- product=product, state=enums.ORDER_STATE_VALIDATED
+ product=product, state=enums.ORDER_STATE_COMPLETED
)
enrollment = factories.EnrollmentFactory(
course_run=course_run1,
@@ -1814,7 +1830,7 @@ def test_api_enrollment_update_detail_authenticated_superuser(self, _mock_set):
)
product = factories.ProductFactory(target_courses=[target_course])
order = factories.OrderFactory(
- product=product, state=enums.ORDER_STATE_VALIDATED
+ product=product, state=enums.ORDER_STATE_COMPLETED
)
enrollment = factories.EnrollmentFactory(
@@ -1841,7 +1857,7 @@ def test_api_enrollment_update_detail_authenticated_owner(self, _mock_set):
factories.OrderFactory(
owner=user,
product__target_courses=[target_course],
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
enrollment = factories.EnrollmentFactory(
@@ -2015,13 +2031,22 @@ def test_api_enrollment_update_was_created_by_order_on_inactive_enrollment(
# tries to update to was_created_by_order field again.
product = factories.ProductFactory(target_courses=[course_run.course])
order = factories.OrderFactory(
- owner=user, product=product, state=enums.ORDER_STATE_SUBMITTED
+ owner=user,
+ product=product,
+ state=enums.ORDER_STATE_PENDING,
+ payment_schedule=[
+ {
+ "amount": "200.00",
+ "due_date": "2024-01-17",
+ "state": enums.PAYMENT_STATE_PAID,
+ },
+ ],
)
+ order.flow.complete()
# - Create an invoice related to the order to mark it as validated and trigger the
# auto enrollment logic on validate transition
InvoiceFactory(order=order, total=order.total)
- order.flow.validate()
# The enrollment should have been activated automatically
enrollment.refresh_from_db()
@@ -2114,7 +2139,7 @@ def test_api_enrollment_update_was_created_by_order_on_order_enrollment(
factories.OrderFactory(
owner=user,
product__target_courses=[target_course],
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
enrollment = factories.EnrollmentFactory(
@@ -2189,7 +2214,7 @@ def test_api_enrollment_update_was_created_by_order_on_order_enrollment(
factories.OrderFactory(
owner=user,
product__target_courses=[course_run.course],
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
response = self.client.put(
diff --git a/src/backend/joanie/tests/core/test_commands_generate_certificates.py b/src/backend/joanie/tests/core/test_commands_generate_certificates.py
index 6eb556319..2e5a45328 100644
--- a/src/backend/joanie/tests/core/test_commands_generate_certificates.py
+++ b/src/backend/joanie/tests/core/test_commands_generate_certificates.py
@@ -49,7 +49,7 @@ def test_commands_generate_certificates_for_credential_product(self):
target_courses=[course_run.course],
)
order = factories.OrderFactory(product=product)
- order.submit()
+ order.init_flow()
certificate_qs = models.Certificate.objects.filter(order=order)
self.assertEqual(certificate_qs.count(), 0)
@@ -82,7 +82,7 @@ def test_commands_generate_certificates_for_certificate_product(self):
order = factories.OrderFactory(
product=product, course=None, enrollment=enrollment, owner=enrollment.user
)
- order.submit()
+ order.init_flow()
certificate_qs = models.Certificate.objects.filter(order=order)
self.assertEqual(certificate_qs.count(), 0)
@@ -112,7 +112,7 @@ def test_commands_generate_certificates_can_be_restricted_to_order(self):
course = factories.CourseFactory(products=[product])
orders = factories.OrderFactory.create_batch(2, product=product, course=course)
for order in orders:
- order.submit()
+ order.init_flow()
certificate_qs = models.Certificate.objects.filter(order__in=orders)
self.assertEqual(certificate_qs.count(), 0)
@@ -148,7 +148,7 @@ def test_commands_generate_certificates_can_be_restricted_to_course(self):
factories.OrderFactory(product=product, course=course_2),
]
for order in orders:
- order.submit()
+ order.init_flow()
certificate_qs = models.Certificate.objects.filter(order__in=orders)
self.assertEqual(certificate_qs.count(), 0)
@@ -187,7 +187,7 @@ def test_commands_generate_certificates_can_be_restricted_to_product(self):
factories.OrderFactory(course=course, product=product_2),
]
for order in orders:
- order.submit()
+ order.init_flow()
certificate_qs = models.Certificate.objects.filter(order__in=orders)
self.assertEqual(certificate_qs.count(), 0)
@@ -235,7 +235,7 @@ def test_commands_generate_certificates_can_be_restricted_to_product_course(self
factories.OrderFactory(course=course_2, product=product_2),
]
for order in orders:
- order.submit()
+ order.init_flow()
certificate_qs = models.Certificate.objects.filter(order__in=orders)
self.assertEqual(certificate_qs.count(), 0)
@@ -290,7 +290,7 @@ def test_commands_generate_certificates_optimizes_db_queries(self):
factories.OrderFactory(course=course, product=product_2),
]
for order in orders:
- order.submit()
+ order.init_flow()
certificate_qs = models.Certificate.objects.filter(order__in=orders)
self.assertEqual(certificate_qs.count(), 0)
diff --git a/src/backend/joanie/tests/core/test_commands_process_payment_schedules.py b/src/backend/joanie/tests/core/test_commands_process_payment_schedules.py
index 3f74b4b55..2f202bdb7 100644
--- a/src/backend/joanie/tests/core/test_commands_process_payment_schedules.py
+++ b/src/backend/joanie/tests/core/test_commands_process_payment_schedules.py
@@ -77,9 +77,9 @@ def test_commands_process_payment_schedules(self):
with (
mock.patch("django.utils.timezone.now", return_value=mocked_now),
mock.patch(
- "joanie.core.tasks.payment_schedule.process_today_installment"
- ) as process_today_installment,
+ "joanie.core.tasks.payment_schedule.debit_pending_installment"
+ ) as debit_pending_installment,
):
call_command("process_payment_schedules")
- process_today_installment.delay.assert_called_once_with(order.id)
+ debit_pending_installment.delay.assert_called_once_with(order.id)
diff --git a/src/backend/joanie/tests/core/test_commands_send_mail_upcoming_debit.py b/src/backend/joanie/tests/core/test_commands_send_mail_upcoming_debit.py
new file mode 100644
index 000000000..d1b4440c2
--- /dev/null
+++ b/src/backend/joanie/tests/core/test_commands_send_mail_upcoming_debit.py
@@ -0,0 +1,68 @@
+"""Tests for the `send_mail_upcoming_debit` management command"""
+
+from datetime import date
+from decimal import Decimal as D
+from unittest import mock
+
+from django.core.management import call_command
+from django.test import TestCase
+from django.test.utils import override_settings
+
+from joanie.core import enums, factories
+
+
+@override_settings(
+ JOANIE_PAYMENT_SCHEDULE_LIMITS={1000: (20, 30, 30, 20)},
+ JOANIE_INSTALLMENT_REMINDER_PERIOD_DAYS=2,
+ DEFAULT_CURRENCY="EUR",
+)
+class SendMailUpcomingDebitManagementCommandTestCase(TestCase):
+ """Test case for the management command `send_mail_upcoming_debit`"""
+
+ @override_settings(JOANIE_INSTALLMENT_REMINDER_PERIOD_DAYS=2)
+ def test_command_send_mail_upcoming_debit_with_installment_reminder_period_of_2_days(
+ self,
+ ):
+ """
+ According to the value configured in the setting `JOANIE_INSTALLMENT_REMINDER_PERIOD_DAYS`,
+ that is 2 days for this test, the command should find the 2nd installment that will
+ be debited next and call the task that is responsible to send an email to the order's
+ owner. The task must be called with the `order.id` and the installment id
+ that is concerned in the order payment schedule.
+ """
+ order = factories.OrderGeneratorFactory(
+ state=enums.ORDER_STATE_PENDING_PAYMENT,
+ product__price=D("1000.00"),
+ product__title="Product 1",
+ )
+ order.payment_schedule[0]["state"] = enums.PAYMENT_STATE_PAID
+ order.payment_schedule[0]["due_date"] = date(2024, 1, 17)
+ order.payment_schedule[1]["id"] = "1932fbc5-d971-48aa-8fee-6d637c3154a5"
+ order.payment_schedule[1]["state"] = enums.PAYMENT_STATE_PENDING
+ order.payment_schedule[1]["due_date"] = date(2024, 2, 17)
+ order.save()
+
+ order_2 = factories.OrderGeneratorFactory(
+ state=enums.ORDER_STATE_PENDING_PAYMENT,
+ product__price=D("1000.00"),
+ product__title="Product 2",
+ )
+ order_2.payment_schedule[0]["state"] = enums.PAYMENT_STATE_PAID
+ order_2.payment_schedule[1]["due_date"] = date(2024, 2, 18)
+ order_2.payment_schedule[1]["state"] = enums.PAYMENT_STATE_PENDING
+ order_2.save()
+
+ with (
+ mock.patch(
+ "django.utils.timezone.localdate", return_value=date(2024, 2, 15)
+ ),
+ mock.patch(
+ "joanie.core.management.commands.send_mail_upcoming_debit"
+ ".send_mail_reminder_installment_debit_task"
+ ) as send_mail_reminder_installment_debit_task,
+ ):
+ call_command("send_mail_upcoming_debit")
+
+ send_mail_reminder_installment_debit_task.delay.assert_called_once_with(
+ order_id=order.id, installment_id="1932fbc5-d971-48aa-8fee-6d637c3154a5"
+ )
diff --git a/src/backend/joanie/tests/core/test_flows_order.py b/src/backend/joanie/tests/core/test_flows_order.py
index d0e35648a..26f48143f 100644
--- a/src/backend/joanie/tests/core/test_flows_order.py
+++ b/src/backend/joanie/tests/core/test_flows_order.py
@@ -4,17 +4,21 @@
# pylint: disable=too-many-lines,too-many-public-methods
import json
-import random
+from datetime import date
from http import HTTPStatus
from unittest import mock
+from django.core import mail
+from django.core.exceptions import ValidationError
from django.test import TestCase
from django.test.utils import override_settings
import responses
+from stockholm import Money
from viewflow.fsm import TransitionNotAllowed
from joanie.core import enums, exceptions, factories
+from joanie.core.factories import CourseRunFactory
from joanie.core.models import CourseState, Enrollment
from joanie.lms_handler import LMSHandler
from joanie.lms_handler.backends.dummy import DummyLMSBackend
@@ -22,7 +26,12 @@
OPENEDX_MODE_HONOR,
OPENEDX_MODE_VERIFIED,
)
-from joanie.payment.factories import BillingAddressDictFactory, InvoiceFactory
+from joanie.payment.backends.dummy import DummyPaymentBackend
+from joanie.payment.factories import (
+ BillingAddressDictFactory,
+ CreditCardFactory,
+ InvoiceFactory,
+)
from joanie.tests.base import BaseLogMixinTestCase
@@ -31,134 +40,51 @@ class OrderFlowsTestCase(TestCase, BaseLogMixinTestCase):
maxDiff = None
- def test_flows_order_validate(self):
+ def test_flow_order_assign(self):
"""
- Order has a validate method which is in charge to enroll owner to courses
- with only one course run if order state is equal to validated.
+ It should set the order state to ORDER_STATE_TO_SAVE_PAYMENT_METHOD
+ when the order has no credit card.
"""
- owner = factories.UserFactory()
- [course, target_course] = factories.CourseFactory.create_batch(2)
-
- # - Link only one course run to target_course
- factories.CourseRunFactory(
- course=target_course,
- state=CourseState.ONGOING_OPEN,
- )
+ order = factories.OrderFactory(credit_card=None)
- product = factories.ProductFactory(
- courses=[course], target_courses=[target_course]
- )
+ order.init_flow(billing_address=BillingAddressDictFactory())
- order = factories.OrderFactory(
- owner=owner,
- product=product,
- course=course,
- )
- order.submit(billing_address=BillingAddressDictFactory())
+ self.assertEqual(order.state, enums.ORDER_STATE_TO_SAVE_PAYMENT_METHOD)
- self.assertEqual(order.state, enums.ORDER_STATE_SUBMITTED)
- self.assertEqual(Enrollment.objects.count(), 0)
-
- # - Create an invoice to mark order as validated
- InvoiceFactory(order=order, total=order.total)
-
- # - Validate the order should automatically enroll user to course run
- with self.assertNumQueries(23):
- order.flow.validate()
-
- self.assertEqual(order.state, enums.ORDER_STATE_VALIDATED)
-
- self.assertEqual(Enrollment.objects.count(), 1)
-
- def test_flows_order_validate_with_contract(self):
+ def test_flow_order_assign_free_product(self):
"""
- Order has a validate method which is in charge to enroll owner to courses
- with only one course run if order state is equal to validated. But if the
- related product has a contract, the user should not be enrolled at this step.
+ It should set the order state to ORDER_STATE_COMPLETED
+ when the order has a free product.
"""
- owner = factories.UserFactory()
- [course, target_course] = factories.CourseFactory.create_batch(2)
+ order = factories.OrderFactory(product__price=0)
- # - Link only one course run to target_course
- factories.CourseRunFactory(
- course=target_course,
- state=CourseState.ONGOING_OPEN,
- )
+ order.init_flow()
- product = factories.ProductFactory(
- courses=[course],
- target_courses=[target_course],
- contract_definition=factories.ContractDefinitionFactory(),
- )
-
- order = factories.OrderFactory(
- owner=owner,
- product=product,
- course=course,
- )
- order.submit(billing_address=BillingAddressDictFactory())
-
- self.assertEqual(order.state, enums.ORDER_STATE_SUBMITTED)
- self.assertEqual(Enrollment.objects.count(), 0)
-
- # - Create an invoice to mark order as validated
- InvoiceFactory(order=order, total=order.total)
-
- # - Validate the order should not have automatically enrolled user to course run
- with self.assertNumQueries(10):
- order.flow.validate()
-
- self.assertEqual(order.state, enums.ORDER_STATE_VALIDATED)
-
- self.assertEqual(Enrollment.objects.count(), 0)
+ self.assertEqual(order.state, enums.ORDER_STATE_COMPLETED)
- def test_flows_order_validate_with_inactive_enrollment(self):
+ def test_flow_order_assign_no_billing_address(self):
"""
- Order has a validate method which is in charge to enroll owner to courses
- with only one course run if order state is equal to validated. If the user has
- already an inactive enrollment, it should be activated.
+ It should raise a TransitionNotAllowed exception
+ when the order has no billing address and the order is not free.
"""
- owner = factories.UserFactory()
- [course, target_course] = factories.CourseFactory.create_batch(2)
+ order = factories.OrderFactory()
- # - Link only one course run to target_course
- course_run = factories.CourseRunFactory(
- course=target_course,
- state=CourseState.ONGOING_OPEN,
- is_listed=True,
- )
+ with self.assertRaises(ValidationError):
+ order.init_flow()
- product = factories.ProductFactory(
- courses=[course], target_courses=[target_course]
- )
+ self.assertEqual(order.state, enums.ORDER_STATE_ASSIGNED)
- order = factories.OrderFactory(
- owner=owner,
- product=product,
- course=course,
- )
- order.submit(billing_address=BillingAddressDictFactory())
-
- # - Create an inactive enrollment for related course run
- enrollment = factories.EnrollmentFactory(
- user=owner, course_run=course_run, is_active=False
- )
-
- self.assertEqual(order.state, enums.ORDER_STATE_SUBMITTED)
- self.assertEqual(Enrollment.objects.count(), 1)
-
- # - Create an invoice to mark order as validated
- InvoiceFactory(order=order, total=order.total)
-
- # - Validate the order should automatically enroll user to course run
- with self.assertNumQueries(21):
- order.flow.validate()
+ def test_flow_order_assign_no_organization(self):
+ """
+ It should raise a TransitionNotAllowed exception
+ when the order has no organization.
+ """
+ order = factories.OrderFactory(organization=None)
- enrollment.refresh_from_db()
- self.assertEqual(order.state, enums.ORDER_STATE_VALIDATED)
+ with self.assertRaises(TransitionNotAllowed):
+ order.init_flow()
- self.assertEqual(Enrollment.objects.count(), 1)
- self.assertEqual(enrollment.is_active, True)
+ self.assertEqual(order.state, enums.ORDER_STATE_DRAFT)
def test_flows_order_cancel(self):
"""
@@ -184,7 +110,7 @@ def test_flows_order_cancel(self):
product=product,
course=course,
)
- order.submit()
+ order.init_flow()
# - As target_course has several course runs, user should not be enrolled automatically
self.assertEqual(Enrollment.objects.count(), 0)
@@ -230,7 +156,7 @@ def test_flows_order_cancel_with_course_implied_in_several_products(self):
product=product_1,
course=course,
)
- order.submit()
+ order.init_flow()
factories.OrderFactory(owner=owner, product=product_2, course=course)
# - As target_course has several course runs, user should not be enrolled automatically
@@ -289,60 +215,68 @@ def test_flows_order_cancel_with_listed_course_run(self):
self.assertEqual(Enrollment.objects.count(), 1)
self.assertEqual(Enrollment.objects.filter(is_active=True).count(), 1)
- def test_flows_order_validate_transition_success(self):
+ def test_flows_order_complete_transition_success(self):
"""
- Test that the validate transition is successful
+ Test that the complete transition is successful
when the order is free or has invoices and is in the
ORDER_STATE_PENDING state
"""
order_invoice = factories.OrderFactory(
product=factories.ProductFactory(price="10.00"),
- state=enums.ORDER_STATE_SUBMITTED,
+ state=enums.ORDER_STATE_PENDING,
+ payment_schedule=[
+ {
+ "amount": "10.00",
+ "due_date": "2024-01-17",
+ "state": enums.PAYMENT_STATE_PAID,
+ }
+ ],
)
InvoiceFactory(order=order_invoice)
- self.assertEqual(order_invoice.flow._can_be_state_validated(), True) # pylint: disable=protected-access
- order_invoice.flow.validate()
- self.assertEqual(order_invoice.state, enums.ORDER_STATE_VALIDATED)
+ self.assertEqual(order_invoice.flow._can_be_state_completed(), True) # pylint: disable=protected-access
+ order_invoice.flow.complete()
+ self.assertEqual(order_invoice.state, enums.ORDER_STATE_COMPLETED)
order_free = factories.OrderFactory(
product=factories.ProductFactory(price="0.00"),
state=enums.ORDER_STATE_DRAFT,
)
- order_free.submit()
- self.assertEqual(order_free.flow._can_be_state_validated(), True) # pylint: disable=protected-access
- # order free are automatically validated without calling the validate method
+ order_free.init_flow()
+
+ self.assertEqual(order_free.flow._can_be_state_completed(), True) # pylint: disable=protected-access
+ # order free are automatically completed without calling the complete method
# but submit need to be called nonetheless
- self.assertEqual(order_free.state, enums.ORDER_STATE_VALIDATED)
+ self.assertEqual(order_free.state, enums.ORDER_STATE_COMPLETED)
with self.assertRaises(TransitionNotAllowed):
- order_free.flow.validate()
+ order_free.flow.complete()
- def test_flows_order_validate_failure(self):
+ def test_flows_order_complete_failure(self):
"""
- Test that the validate transition fails when the
+ Test that the complete transition fails when the
order is not free and has no invoices
"""
order_no_invoice = factories.OrderFactory(
product=factories.ProductFactory(price="10.00"),
state=enums.ORDER_STATE_PENDING,
)
- self.assertEqual(order_no_invoice.flow._can_be_state_validated(), False) # pylint: disable=protected-access
+ self.assertEqual(order_no_invoice.flow._can_be_state_completed(), False) # pylint: disable=protected-access
with self.assertRaises(TransitionNotAllowed):
- order_no_invoice.flow.validate()
+ order_no_invoice.flow.complete()
self.assertEqual(order_no_invoice.state, enums.ORDER_STATE_PENDING)
- def test_flows_order_validate_failure_when_not_pending(self):
+ def test_flows_order_complete_failure_when_not_pending(self):
"""
- Test that the validate transition fails when the
+ Test that the complete transition fails when the
order is not in the ORDER_STATE_PENDING state
"""
order = factories.OrderFactory(
product=factories.ProductFactory(price="0.00"),
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
- self.assertEqual(order.flow._can_be_state_validated(), True) # pylint: disable=protected-access
+ self.assertEqual(order.flow._can_be_state_completed(), True) # pylint: disable=protected-access
with self.assertRaises(TransitionNotAllowed):
- order.flow.validate()
- self.assertEqual(order.state, enums.ORDER_STATE_VALIDATED)
+ order.flow.complete()
+ self.assertEqual(order.state, enums.ORDER_STATE_COMPLETED)
@responses.activate
@override_settings(
@@ -390,9 +324,9 @@ def test_flows_order_validate_auto_enroll(self):
# Create an order
order = factories.OrderFactory(product=product, owner=user)
- order.submit()
+ order.init_flow()
- self.assertEqual(order.state, enums.ORDER_STATE_VALIDATED)
+ self.assertEqual(order.state, enums.ORDER_STATE_COMPLETED)
self.assertEqual(len(responses.calls), 2)
self.assertEqual(responses.calls[1].request.url, url)
@@ -450,8 +384,8 @@ def test_flows_order_validate_auto_enroll_failure(self):
# Create an order
order = factories.OrderFactory(product=product, owner=user)
- order.submit()
- self.assertEqual(order.state, enums.ORDER_STATE_VALIDATED)
+ order.init_flow()
+ self.assertEqual(order.state, enums.ORDER_STATE_COMPLETED)
self.assertEqual(Enrollment.objects.count(), 1)
@@ -502,9 +436,9 @@ def test_flows_order_validate_auto_enroll_edx_failure(self):
# Create an order
order = factories.OrderFactory(product=product, owner=user)
- order.submit()
+ order.init_flow()
- self.assertEqual(order.state, enums.ORDER_STATE_VALIDATED)
+ self.assertEqual(order.state, enums.ORDER_STATE_COMPLETED)
self.assertEqual(len(responses.calls), 2)
self.assertEqual(responses.calls[1].request.url, url)
@@ -537,9 +471,9 @@ def test_flows_order_validate_auto_enroll_edx_failure(self):
}
]
)
- def test_flows_order_validate_preexisting_enrollments_targeted(self):
+ def test_flows_order_complete_preexisting_enrollments_targeted(self):
"""
- When an order is validated, if the user was previously enrolled for free in any of the
+ When an order is completed, if the user was previously enrolled for free in any of the
course runs targeted by the purchased product, we should change their enrollment mode on
these course runs to "verified".
"""
@@ -582,9 +516,9 @@ def test_flows_order_validate_preexisting_enrollments_targeted(self):
course_run=course_run, is_active=True, user=user
)
order = factories.OrderFactory(product=product, owner=user)
- order.submit()
+ order.init_flow()
- self.assertEqual(order.state, enums.ORDER_STATE_VALIDATED)
+ self.assertEqual(order.state, enums.ORDER_STATE_COMPLETED)
self.assertEqual(len(responses.calls), 4)
self.assertEqual(responses.calls[3].request.url, url)
@@ -613,9 +547,9 @@ def test_flows_order_validate_preexisting_enrollments_targeted(self):
}
]
)
- def test_flows_order_validate_preexisting_enrollments_targeted_moodle(self):
+ def test_flows_order_complete_preexisting_enrollments_targeted_moodle(self):
"""
- When an order is validated, if the user was previously enrolled for free in any of the
+ When an order is completed, if the user was previously enrolled for free in any of the
course runs targeted by the purchased product, we should change their enrollment mode on
these course runs to "verified".
"""
@@ -715,9 +649,9 @@ def test_flows_order_validate_preexisting_enrollments_targeted_moodle(self):
)
order = factories.OrderFactory(product=product, owner__username="student")
- order.submit()
+ order.init_flow()
- self.assertEqual(order.state, enums.ORDER_STATE_VALIDATED)
+ self.assertEqual(order.state, enums.ORDER_STATE_COMPLETED)
self.assertEqual(len(responses.calls), 3)
@@ -803,22 +737,20 @@ def test_flows_order_validate_auto_enroll_moodle_failure(self):
# - Submit the order to trigger the validation as it is free
order = factories.OrderFactory(product=product)
- order.submit()
+ order.init_flow()
order.refresh_from_db()
- self.assertEqual(order.state, enums.ORDER_STATE_VALIDATED)
+ self.assertEqual(order.state, enums.ORDER_STATE_COMPLETED)
self.assertEqual(len(responses.calls), 3)
def test_flows_order_cancel_success(self):
"""Test that the cancel transition is successful from any state"""
-
- order = factories.OrderFactory(
- product=factories.ProductFactory(price="0.00"),
- state=random.choice(enums.ORDER_STATE_CHOICES)[0],
- )
- order.flow.cancel()
- self.assertEqual(order.state, enums.ORDER_STATE_CANCELED)
+ for state, _ in enums.ORDER_STATE_CHOICES:
+ with self.subTest(state=state):
+ order = factories.OrderFactory(state=state)
+ order.flow.cancel()
+ self.assertEqual(order.state, enums.ORDER_STATE_CANCELED)
@responses.activate
def test_flows_order_cancel_certificate_product_openedx_enrollment_mode(self):
@@ -838,12 +770,13 @@ def test_flows_order_cancel_certificate_product_openedx_enrollment_mode(self):
course_run__is_listed=True,
course_run__resource_link=resource_link,
user=user,
+ is_active=True,
)
order = factories.OrderFactory(
course=None,
product=product,
enrollment=enrollment,
- state="validated",
+ state=enums.ORDER_STATE_COMPLETED,
owner=user,
)
@@ -926,7 +859,7 @@ def test_flows_order_cancel_certificate_product_moodle(self):
course=None,
product=product,
enrollment=enrollment,
- state="validated",
+ state=enums.ORDER_STATE_COMPLETED,
)
backend = LMSHandler.select_lms(resource_link)
@@ -1030,12 +963,13 @@ def test_flows_order_cancel_certificate_product_enrollment_state_failed(self):
course_run__course=course,
course_run__is_listed=True,
course_run__state=CourseState.FUTURE_OPEN,
+ is_active=True,
)
order = factories.OrderFactory(
course=None,
product=product,
enrollment=enrollment,
- state="validated",
+ state=enums.ORDER_STATE_COMPLETED,
)
def enrollment_error(*args, **kwargs):
@@ -1046,7 +980,6 @@ def enrollment_error(*args, **kwargs):
):
order.flow.cancel()
- self.assertEqual(enrollment.state, "failed")
enrollment.refresh_from_db()
self.assertEqual(enrollment.state, "failed")
@@ -1059,22 +992,22 @@ def test_flows_order_complete_all_paid(self):
payment_schedule=[
{
"amount": "200.00",
- "due_date": "2024-01-17T00:00:00+00:00",
+ "due_date": "2024-01-17",
"state": enums.PAYMENT_STATE_PAID,
},
{
"amount": "300.00",
- "due_date": "2024-02-17T00:00:00+00:00",
+ "due_date": "2024-02-17",
"state": enums.PAYMENT_STATE_PAID,
},
{
"amount": "300.00",
- "due_date": "2024-03-17T00:00:00+00:00",
+ "due_date": "2024-03-17",
"state": enums.PAYMENT_STATE_PAID,
},
{
"amount": "199.99",
- "due_date": "2024-04-17T00:00:00+00:00",
+ "due_date": "2024-04-17",
"state": enums.PAYMENT_STATE_PAID,
},
],
@@ -1094,22 +1027,22 @@ def test_flows_order_failed_payment_to_complete(self):
payment_schedule=[
{
"amount": "200.00",
- "due_date": "2024-01-17T00:00:00+00:00",
+ "due_date": "2024-01-17",
"state": enums.PAYMENT_STATE_PAID,
},
{
"amount": "300.00",
- "due_date": "2024-02-17T00:00:00+00:00",
+ "due_date": "2024-02-17",
"state": enums.PAYMENT_STATE_PAID,
},
{
"amount": "300.00",
- "due_date": "2024-03-17T00:00:00+00:00",
+ "due_date": "2024-03-17",
"state": enums.PAYMENT_STATE_PAID,
},
{
"amount": "199.99",
- "due_date": "2024-04-17T00:00:00+00:00",
+ "due_date": "2024-04-17",
"state": enums.PAYMENT_STATE_PAID,
},
],
@@ -1121,30 +1054,30 @@ def test_flows_order_failed_payment_to_complete(self):
def test_flows_order_complete_first_paid(self):
"""
- Test that the complete transition sets pending_payment state
- when installments are left to be paid
+ Test that the pending_payment transition failed when the first installment
+ is not paid.
"""
order = factories.OrderFactory(
state=enums.ORDER_STATE_PENDING,
payment_schedule=[
{
"amount": "200.00",
- "due_date": "2024-01-17T00:00:00+00:00",
+ "due_date": "2024-01-17",
"state": enums.PAYMENT_STATE_PAID,
},
{
"amount": "300.00",
- "due_date": "2024-02-17T00:00:00+00:00",
+ "due_date": "2024-02-17",
"state": enums.PAYMENT_STATE_PENDING,
},
{
"amount": "300.00",
- "due_date": "2024-03-17T00:00:00+00:00",
+ "due_date": "2024-03-17",
"state": enums.PAYMENT_STATE_PENDING,
},
{
"amount": "199.99",
- "due_date": "2024-04-17T00:00:00+00:00",
+ "due_date": "2024-04-17",
"state": enums.PAYMENT_STATE_PENDING,
},
],
@@ -1154,6 +1087,42 @@ def test_flows_order_complete_first_paid(self):
self.assertEqual(order.state, enums.ORDER_STATE_PENDING_PAYMENT)
+ def test_flows_order_pending_payment_failed_with_unpaid_first_installment(self):
+ """
+ Test that the complete transition sets pending_payment state
+ when installments are left to be paid
+ """
+ order = factories.OrderFactory(
+ state=enums.ORDER_STATE_PENDING,
+ payment_schedule=[
+ {
+ "amount": "200.00",
+ "due_date": "2024-01-17",
+ "state": enums.PAYMENT_STATE_PENDING,
+ },
+ {
+ "amount": "300.00",
+ "due_date": "2024-02-17",
+ "state": enums.PAYMENT_STATE_PENDING,
+ },
+ {
+ "amount": "300.00",
+ "due_date": "2024-03-17",
+ "state": enums.PAYMENT_STATE_PENDING,
+ },
+ {
+ "amount": "199.99",
+ "due_date": "2024-04-17",
+ "state": enums.PAYMENT_STATE_PENDING,
+ },
+ ],
+ )
+
+ with self.assertRaises(TransitionNotAllowed):
+ order.flow.pending_payment()
+
+ self.assertEqual(order.state, enums.ORDER_STATE_PENDING)
+
def test_flows_order_complete_first_payment_failed(self):
"""
Test that the complete transition sets no_payment state
@@ -1164,22 +1133,22 @@ def test_flows_order_complete_first_payment_failed(self):
payment_schedule=[
{
"amount": "200.00",
- "due_date": "2024-01-17T00:00:00+00:00",
+ "due_date": "2024-01-17",
"state": enums.PAYMENT_STATE_REFUSED,
},
{
"amount": "300.00",
- "due_date": "2024-02-17T00:00:00+00:00",
+ "due_date": "2024-02-17",
"state": enums.PAYMENT_STATE_PENDING,
},
{
"amount": "300.00",
- "due_date": "2024-03-17T00:00:00+00:00",
+ "due_date": "2024-03-17",
"state": enums.PAYMENT_STATE_PENDING,
},
{
"amount": "199.99",
- "due_date": "2024-04-17T00:00:00+00:00",
+ "due_date": "2024-04-17",
"state": enums.PAYMENT_STATE_PENDING,
},
],
@@ -1199,22 +1168,22 @@ def test_flows_order_complete_middle_paid(self):
payment_schedule=[
{
"amount": "200.00",
- "due_date": "2024-01-17T00:00:00+00:00",
+ "due_date": "2024-01-17",
"state": enums.PAYMENT_STATE_PAID,
},
{
"amount": "300.00",
- "due_date": "2024-02-17T00:00:00+00:00",
+ "due_date": "2024-02-17",
"state": enums.PAYMENT_STATE_PAID,
},
{
"amount": "300.00",
- "due_date": "2024-03-17T00:00:00+00:00",
+ "due_date": "2024-03-17",
"state": enums.PAYMENT_STATE_PENDING,
},
{
"amount": "199.99",
- "due_date": "2024-04-17T00:00:00+00:00",
+ "due_date": "2024-04-17",
"state": enums.PAYMENT_STATE_PENDING,
},
],
@@ -1234,22 +1203,22 @@ def test_flows_order_complete_middle_payment_failed(self):
payment_schedule=[
{
"amount": "200.00",
- "due_date": "2024-01-17T00:00:00+00:00",
+ "due_date": "2024-01-17",
"state": enums.PAYMENT_STATE_PAID,
},
{
"amount": "300.00",
- "due_date": "2024-02-17T00:00:00+00:00",
+ "due_date": "2024-02-17",
"state": enums.PAYMENT_STATE_REFUSED,
},
{
"amount": "300.00",
- "due_date": "2024-03-17T00:00:00+00:00",
+ "due_date": "2024-03-17",
"state": enums.PAYMENT_STATE_PENDING,
},
{
"amount": "199.99",
- "due_date": "2024-04-17T00:00:00+00:00",
+ "due_date": "2024-04-17",
"state": enums.PAYMENT_STATE_PENDING,
},
],
@@ -1269,22 +1238,22 @@ def test_flows_order_no_payment_to_pending_payment(self):
payment_schedule=[
{
"amount": "200.00",
- "due_date": "2024-01-17T00:00:00+00:00",
+ "due_date": "2024-01-17",
"state": enums.PAYMENT_STATE_PAID,
},
{
"amount": "300.00",
- "due_date": "2024-02-17T00:00:00+00:00",
+ "due_date": "2024-02-17",
"state": enums.PAYMENT_STATE_PENDING,
},
{
"amount": "300.00",
- "due_date": "2024-03-17T00:00:00+00:00",
+ "due_date": "2024-03-17",
"state": enums.PAYMENT_STATE_PENDING,
},
{
"amount": "199.99",
- "due_date": "2024-04-17T00:00:00+00:00",
+ "due_date": "2024-04-17",
"state": enums.PAYMENT_STATE_PENDING,
},
],
@@ -1304,22 +1273,22 @@ def test_flows_order_failed_payment_to_pending_payment(self):
payment_schedule=[
{
"amount": "200.00",
- "due_date": "2024-01-17T00:00:00+00:00",
+ "due_date": "2024-01-17",
"state": enums.PAYMENT_STATE_PAID,
},
{
"amount": "300.00",
- "due_date": "2024-02-17T00:00:00+00:00",
+ "due_date": "2024-02-17",
"state": enums.PAYMENT_STATE_PAID,
},
{
"amount": "300.00",
- "due_date": "2024-03-17T00:00:00+00:00",
+ "due_date": "2024-03-17",
"state": enums.PAYMENT_STATE_PENDING,
},
{
"amount": "199.99",
- "due_date": "2024-04-17T00:00:00+00:00",
+ "due_date": "2024-04-17",
"state": enums.PAYMENT_STATE_PENDING,
},
],
@@ -1328,3 +1297,386 @@ def test_flows_order_failed_payment_to_pending_payment(self):
order.flow.pending_payment()
self.assertEqual(order.state, enums.ORDER_STATE_PENDING_PAYMENT)
+
+ def test_flows_order_update_not_free_no_card_with_contract(self):
+ """
+ Test that the order state is set to `to_sign`
+ when the order is not free, owner has no card and the order has a contract.
+ """
+ order = factories.OrderFactory(
+ state=enums.ORDER_STATE_ASSIGNED,
+ credit_card=None,
+ payment_schedule=[
+ {
+ "amount": "200.00",
+ "due_date": "2024-01-17",
+ "state": enums.PAYMENT_STATE_PENDING,
+ },
+ ],
+ )
+ factories.ContractFactory(
+ order=order,
+ definition=factories.ContractDefinitionFactory(),
+ )
+
+ order.flow.update()
+
+ order.refresh_from_db()
+ self.assertEqual(order.state, enums.ORDER_STATE_TO_SIGN)
+
+ def test_flows_order_update_not_free_no_card_no_contract(self):
+ """
+ Test that the order state is set to `to_save_payment_method` when the order is not free,
+ owner has no card and the order has no contract.
+ """
+ order = factories.OrderFactory(
+ state=enums.ORDER_STATE_ASSIGNED,
+ credit_card=None,
+ )
+
+ order.flow.update()
+
+ order.refresh_from_db()
+ self.assertEqual(order.state, enums.ORDER_STATE_TO_SAVE_PAYMENT_METHOD)
+
+ def test_flows_order_update_not_free_with_card_no_contract(self):
+ """
+ Test that the order state is set to `pending` when the order is not free,
+ owner has a card and the order has no contract.
+ """
+ credit_card = CreditCardFactory(
+ initial_issuer_transaction_identifier="4575676657929351"
+ )
+ run = factories.CourseRunFactory(state=CourseState.ONGOING_OPEN)
+ order = factories.OrderFactory(
+ state=enums.ORDER_STATE_ASSIGNED,
+ owner=credit_card.owner,
+ product__target_courses=[run.course],
+ )
+
+ order.flow.update()
+
+ order.refresh_from_db()
+ self.assertEqual(order.state, enums.ORDER_STATE_PENDING)
+
+ def test_flows_order_update_not_free_with_card_with_contract(self):
+ """
+ Test that the order state is set to `to_sign` when the order is not free,
+ owner has a card and the order has a contract.
+ """
+ order = factories.OrderFactory(state=enums.ORDER_STATE_ASSIGNED)
+ factories.ContractFactory(
+ order=order,
+ definition=factories.ContractDefinitionFactory(),
+ )
+
+ order.flow.update()
+
+ order.refresh_from_db()
+ self.assertEqual(order.state, enums.ORDER_STATE_TO_SIGN)
+
+ def test_flows_order_update_free_no_contract(self):
+ """
+ Test that the order state is set to `completed` when the order is free and has no contract.
+ """
+ order = factories.OrderFactory(
+ state=enums.ORDER_STATE_ASSIGNED,
+ product=factories.ProductFactory(price="0.00"),
+ )
+
+ order.flow.update()
+
+ order.refresh_from_db()
+ self.assertEqual(order.state, enums.ORDER_STATE_COMPLETED)
+
+ def test_flows_order_update_free_with_contract(self):
+ """
+ Test that the order state is set to `to_sign` when the order is free and has a contract.
+ """
+ order = factories.OrderFactory(
+ state=enums.ORDER_STATE_ASSIGNED,
+ product=factories.ProductFactory(price="0.00"),
+ )
+ factories.ContractFactory(
+ order=order,
+ definition=factories.ContractDefinitionFactory(),
+ )
+
+ order.flow.update()
+
+ order.refresh_from_db()
+ self.assertEqual(order.state, enums.ORDER_STATE_TO_SIGN)
+
+ def test_flows_order_pending(self):
+ """
+ Test that the pending transition is successful if the order is
+ in the ASSIGNED, TO_SIGN_AND_TO_SAVE_PAYMENT_METHOD, TO_SAVE_PAYMENT_METHOD,
+ or TO_SIGN state.
+ """
+ for state in [
+ enums.ORDER_STATE_ASSIGNED,
+ enums.ORDER_STATE_TO_SAVE_PAYMENT_METHOD,
+ enums.ORDER_STATE_SIGNING,
+ ]:
+ with self.subTest(state=state):
+ run = factories.CourseRunFactory(state=CourseState.ONGOING_OPEN)
+ order = factories.OrderFactory(
+ state=state, product__target_courses=[run.course]
+ )
+ order.flow.pending()
+ self.assertEqual(order.state, enums.ORDER_STATE_PENDING)
+
+ def test_flows_order_update(self):
+ """
+ Test that updating flow is transitioning as expected for all states.
+ """
+ for state, _ in enums.ORDER_STATE_CHOICES:
+ with self.subTest(state=state):
+ order = factories.OrderGeneratorFactory(state=state)
+ order.flow.update()
+
+ if state == enums.ORDER_STATE_ASSIGNED:
+ self.assertEqual(
+ order.state, enums.ORDER_STATE_TO_SAVE_PAYMENT_METHOD
+ )
+ else:
+ self.assertEqual(order.state, state)
+
+ def test_flows_order_pending_transition_generate_schedule(self):
+ """
+ Test that a payment schedule is generated when a not free order transitions
+ to `pending` state.
+ """
+ target_courses = factories.CourseFactory.create_batch(
+ 1,
+ course_runs=CourseRunFactory.create_batch(
+ 1, state=CourseState.ONGOING_OPEN
+ ),
+ )
+ product = factories.ProductFactory(
+ price="100.00", target_courses=target_courses
+ )
+ order = factories.OrderFactory(
+ state=enums.ORDER_STATE_TO_SAVE_PAYMENT_METHOD,
+ credit_card=CreditCardFactory(),
+ product=product,
+ )
+
+ self.assertIsNone(order.payment_schedule)
+
+ order.flow.update()
+
+ self.assertEqual(order.state, enums.ORDER_STATE_PENDING)
+ self.assertIsNotNone(order.payment_schedule)
+
+ @override_settings(JOANIE_CATALOG_NAME="Test Catalog")
+ @override_settings(JOANIE_CATALOG_BASE_URL="https://richie.education")
+ def test_flows_order_save_payment_method_to_pending_mail_sent_confirming_subscription(
+ self,
+ ):
+ """
+ Test the post transition success action of an order when the transition
+ goes from TO_SAVE_PAYMENT_METHOD to PENDING is successful, it should trigger the
+ email confirmation the subscription that is sent to the user.
+ """
+ for state in [
+ enums.ORDER_STATE_TO_SAVE_PAYMENT_METHOD,
+ ]:
+ with self.subTest(state=state):
+ user = factories.UserFactory(
+ email="sam@fun-test.fr",
+ language="en-us",
+ first_name="John",
+ last_name="Doe",
+ )
+ target_courses = factories.CourseFactory.create_batch(
+ 1,
+ course_runs=CourseRunFactory.create_batch(
+ 1, state=CourseState.ONGOING_OPEN
+ ),
+ )
+ product = factories.ProductFactory(
+ price="100.00", target_courses=target_courses
+ )
+ order = factories.OrderFactory(
+ state=state,
+ owner=user,
+ credit_card=CreditCardFactory(),
+ product=product,
+ )
+ order.flow.pending()
+ self.assertEqual(order.state, enums.ORDER_STATE_PENDING)
+
+ # check email has been sent
+ self.assertEqual(len(mail.outbox), 1)
+
+ # check we send it to the right email
+ self.assertEqual(mail.outbox[0].to[0], user.email)
+
+ email_content = " ".join(mail.outbox[0].body.split())
+ self.assertIn("Your order has been confirmed.", email_content)
+ self.assertIn("Thank you very much for your purchase!", email_content)
+ self.assertIn(order.product.title, email_content)
+ # check it's the right object
+ self.assertEqual(mail.outbox[0].subject, "Subscription confirmed!")
+ self.assertIn("Hello", email_content)
+ self.assertNotIn("None", email_content)
+ # emails are generated from mjml format, test rendering of email doesn't
+ # contain any trans tag, it might happen if \n are generated
+ self.assertNotIn("trans ", email_content)
+ # catalog url is included in the email
+ self.assertIn("https://richie.education", email_content)
+
+ @override_settings(JOANIE_CATALOG_NAME="Test Catalog")
+ @override_settings(JOANIE_CATALOG_BASE_URL="https://richie.education")
+ def test_flows_order_signing_to_pending_mail_sent_confirming_subscription(self):
+ """
+ Test the post transition success action of an order when the transition
+ goes from SIGNING to PENDING is successful, it should trigger the
+ email confirmation the subscription that is sent to the user.
+ """
+ for state in [
+ enums.ORDER_STATE_SIGNING,
+ ]:
+ with self.subTest(state=state):
+ user = factories.UserFactory(
+ email="sam@fun-test.fr",
+ language="en-us",
+ first_name="John",
+ last_name="Doe",
+ )
+ target_courses = factories.CourseFactory.create_batch(
+ 1,
+ course_runs=CourseRunFactory.create_batch(
+ 1, state=CourseState.ONGOING_OPEN
+ ),
+ )
+ product = factories.ProductFactory(
+ price="100.00", target_courses=target_courses
+ )
+ order = factories.OrderFactory(
+ state=state,
+ owner=user,
+ credit_card=CreditCardFactory(),
+ product=product,
+ )
+ order.flow.pending()
+ self.assertEqual(order.state, enums.ORDER_STATE_PENDING)
+
+ # check email has been sent
+ self.assertEqual(len(mail.outbox), 1)
+
+ # check we send it to the right email
+ self.assertEqual(mail.outbox[0].to[0], user.email)
+
+ email_content = " ".join(mail.outbox[0].body.split())
+ self.assertIn("Your order has been confirmed.", email_content)
+ self.assertIn("Thank you very much for your purchase!", email_content)
+ self.assertIn(order.product.title, email_content)
+ # check it's the right object
+ self.assertEqual(mail.outbox[0].subject, "Subscription confirmed!")
+ self.assertIn("Hello", email_content)
+ self.assertNotIn("None", email_content)
+ # emails are generated from mjml format, test rendering of email doesn't
+ # contain any trans tag, it might happen if \n are generated
+ self.assertNotIn("trans ", email_content)
+ # catalog url is included in the email
+ self.assertIn("https://richie.education", email_content)
+
+ @override_settings(
+ JOANIE_PAYMENT_SCHEDULE_LIMITS={
+ 5: (30, 70),
+ },
+ )
+ @mock.patch.object(
+ DummyPaymentBackend,
+ "create_zero_click_payment",
+ side_effect=DummyPaymentBackend().create_zero_click_payment,
+ )
+ def test_flows_order_pending_transition_trigger_payment_installment_due_date_is_current_day(
+ self, mock_create_zero_click_payment
+ ):
+ """
+ Test that the post transition success action from the state
+ `ORDER_STATE_TO_SAVE_PAYMENT_METHOD` to `ORDER_STATE_PENDING` should trigger a payment if
+ when the first installment's due date of the payment schedule is on the current day.
+ This may happen when the course has already started.
+ """
+ order = factories.OrderGeneratorFactory(
+ owner=factories.UserFactory(),
+ state=enums.ORDER_STATE_TO_SAVE_PAYMENT_METHOD,
+ product__price="5.00",
+ product__title="Product 1",
+ product__target_courses=factories.CourseFactory.create_batch(
+ 1,
+ course_runs=CourseRunFactory.create_batch(
+ 1,
+ state=CourseState.ONGOING_OPEN,
+ is_listed=True,
+ ),
+ ),
+ )
+ order.payment_schedule[0]["id"] = "d9356dd7-19a6-4695-b18e-ad93af41424a"
+ order.payment_schedule[0]["due_date"] = date(2024, 3, 17)
+ order.save()
+ order.credit_card = CreditCardFactory(owner=order.owner)
+
+ with mock.patch(
+ "django.utils.timezone.localdate", return_value=date(2024, 3, 17)
+ ):
+ order.flow.update()
+
+ mock_create_zero_click_payment.assert_called_once_with(
+ order=order,
+ credit_card_token=order.credit_card.token,
+ installment={
+ "id": "d9356dd7-19a6-4695-b18e-ad93af41424a",
+ "amount": Money("1.5"),
+ "due_date": date(2024, 3, 17),
+ "state": enums.PAYMENT_STATE_PENDING,
+ },
+ )
+
+ self.assertEqual(order.state, enums.ORDER_STATE_PENDING)
+
+ @mock.patch.object(
+ DummyPaymentBackend,
+ "create_zero_click_payment",
+ side_effect=DummyPaymentBackend().create_zero_click_payment,
+ )
+ def test_flows_order_pending_transition_should_not_trigger_payment_if_due_date_is_next_day(
+ self, mock_create_zero_click_payment
+ ):
+ """
+ Test that the pending transition success from `ORDER_STATE_TO_SAVE_PAYMENT_METHOD` to
+ `ORDER_STATE_PENDING` but it does not trigger a payment when the first installment's
+ due date is the next day and not on the current day. In our case, the cronjob
+ will take care to process the upcoming payment the following day, so the order must be
+ in 'pending' state at the end.
+ """
+ order = factories.OrderGeneratorFactory(
+ owner=factories.UserFactory(),
+ state=enums.ORDER_STATE_TO_SAVE_PAYMENT_METHOD,
+ product__title="Product 2",
+ product__target_courses=factories.CourseFactory.create_batch(
+ 1,
+ course_runs=CourseRunFactory.create_batch(
+ 1,
+ state=CourseState.ONGOING_OPEN,
+ is_listed=True,
+ ),
+ ),
+ )
+ order.payment_schedule[0]["id"] = "d9356dd7-19a6-4695-b18e-ad93af41424a"
+ order.payment_schedule[0]["due_date"] = date(2024, 3, 18)
+ order.save()
+ order.credit_card = CreditCardFactory(owner=order.owner)
+
+ with mock.patch(
+ "django.utils.timezone.localdate", return_value=date(2024, 3, 17)
+ ):
+ order.flow.update()
+
+ mock_create_zero_click_payment.assert_not_called()
+
+ self.assertEqual(order.state, enums.ORDER_STATE_PENDING)
diff --git a/src/backend/joanie/tests/core/test_helpers.py b/src/backend/joanie/tests/core/test_helpers.py
index 1df1b9b19..98faaeda0 100644
--- a/src/backend/joanie/tests/core/test_helpers.py
+++ b/src/backend/joanie/tests/core/test_helpers.py
@@ -60,7 +60,7 @@ def test_helpers_get_or_generate_certificate_needs_gradable_course_runs(self):
)
course = factories.CourseFactory(products=[product])
order = factories.OrderFactory(product=product, course=course)
- order.submit()
+ order.init_flow()
certificate_qs = models.Certificate.objects.filter(order=order)
self.assertEqual(certificate_qs.count(), 0)
@@ -101,7 +101,7 @@ def test_helpers_get_or_generate_certificate_needs_enrollments_has_been_passed(
)
course = factories.CourseFactory(products=[product])
order = factories.OrderFactory(product=product, course=course)
- order.submit()
+ order.init_flow()
certificate_qs = models.Certificate.objects.filter(order=order)
enrollment = models.Enrollment.objects.get(course_run_id=course_run.id)
self.assertEqual(certificate_qs.count(), 0)
@@ -148,7 +148,7 @@ def test_helpers_get_or_generate_certificate(self):
)
course = factories.CourseFactory(products=[product])
order = factories.OrderFactory(product=product, course=course)
- order.submit()
+ order.init_flow()
certificate_qs = models.Certificate.objects.filter(order=order)
self.assertEqual(certificate_qs.count(), 0)
@@ -201,7 +201,7 @@ def test_helpers_generate_certificates_for_orders(self):
]
for order in orders[0:-1]:
- order.submit()
+ order.init_flow()
certificate_qs = models.Certificate.objects.filter(order__in=orders)
diff --git a/src/backend/joanie/tests/core/test_models_address.py b/src/backend/joanie/tests/core/test_models_address.py
index 4c393e62c..5b4696385 100644
--- a/src/backend/joanie/tests/core/test_models_address.py
+++ b/src/backend/joanie/tests/core/test_models_address.py
@@ -439,3 +439,69 @@ def test_models_address_create_two_address_with_two_organizations_and_both_none_
)
self.assertEqual(address_1.is_main, True)
self.assertEqual(address_2.is_main, True)
+
+ def test_models_address_unique_constraint_one_address_per_user(self):
+ """
+ Check the unique constraint `unique_address_per_user`
+ that protects to add in the database 2 identical addresses for a user.
+ """
+ owner = factories.UserFactory()
+ factories.AddressFactory(
+ owner=owner,
+ address="1 rue de l'exemple",
+ postcode="75000",
+ city="Paris",
+ country="FR",
+ first_name="firstname",
+ last_name="lastname",
+ )
+
+ with self.assertRaises(ValidationError) as context:
+ factories.AddressFactory(
+ owner=owner,
+ address="1 rue de l'exemple",
+ postcode="75000",
+ city="Paris",
+ country="FR",
+ first_name="firstname",
+ last_name="lastname",
+ )
+
+ self.assertEqual(
+ str(context.exception.messages[0]),
+ "Address with this Owner, Address, Postcode, City, "
+ "Country, First name and Last name already exists.",
+ )
+
+ def test_models_address_unique_constraint_one_address_per_organization(self):
+ """
+ Check the unique constraint `unique_address_per_organization`
+ that protects to add in the database 2 identical addresses for an organization.
+ """
+ organization = factories.OrganizationFactory()
+ factories.AddressFactory(
+ organization=organization,
+ address="2 rue de l'exemple",
+ postcode="75000",
+ city="Paris",
+ country="FR",
+ first_name="firstname",
+ last_name="lastname",
+ )
+
+ with self.assertRaises(ValidationError) as context:
+ factories.AddressFactory(
+ organization=organization,
+ address="2 rue de l'exemple",
+ postcode="75000",
+ city="Paris",
+ country="FR",
+ first_name="firstname",
+ last_name="lastname",
+ )
+
+ self.assertEqual(
+ str(context.exception.messages[0]),
+ "Address with this Organization, Address, Postcode, City, "
+ "Country, First name and Last name already exists.",
+ )
diff --git a/src/backend/joanie/tests/core/test_models_course.py b/src/backend/joanie/tests/core/test_models_course.py
index 9ada6244e..48ee9b7fc 100644
--- a/src/backend/joanie/tests/core/test_models_course.py
+++ b/src/backend/joanie/tests/core/test_models_course.py
@@ -208,8 +208,8 @@ def test_models_course_get_abilities_preset_role(self):
class CourseStateModelsTestCase(TestCase):
"""
- Unit test suite for computing a date to display on the course glimpse depending on the state
- of its related course runs:
+ Unit test suite for computing a date to display on the course glimpse depending on
+ the state of its related course runs:
0: a run is ongoing and open for enrollment > "closing on": {enrollment_end}
1: a run is future and open for enrollment > "starting on": {start}
2: a run is future and not yet open or already closed for enrollment >
@@ -387,3 +387,56 @@ def test_models_course_get_selling_organizations_with_product(self):
with self.assertNumQueries(1):
self.assertEqual(organizations.count(), 2)
+
+ def test_models_course_get_equivalent_course_run_dates(self):
+ """
+ Check that course dates are processed
+ by aggregating target course runs dates as expected.
+ """
+ earliest_start_date = timezone.now() - timedelta(days=1)
+ latest_end_date = timezone.now() + timedelta(days=2)
+ latest_enrollment_start_date = timezone.now() - timedelta(days=2)
+ earliest_enrollment_end_date = timezone.now() + timedelta(days=1)
+ course = factories.CourseFactory()
+ factories.CourseRunFactory(
+ course=course,
+ start=earliest_start_date,
+ end=latest_end_date,
+ enrollment_start=latest_enrollment_start_date - timedelta(days=1),
+ enrollment_end=earliest_enrollment_end_date + timedelta(days=1),
+ )
+ factories.CourseRunFactory(
+ course=course,
+ start=earliest_start_date + timedelta(days=1),
+ end=latest_end_date - timedelta(days=1),
+ enrollment_start=latest_enrollment_start_date,
+ enrollment_end=earliest_enrollment_end_date,
+ )
+
+ self.assertEqual(
+ course.get_equivalent_course_run_dates(),
+ {
+ "start": earliest_start_date,
+ "end": latest_end_date,
+ "enrollment_start": latest_enrollment_start_date,
+ "enrollment_end": earliest_enrollment_end_date,
+ },
+ )
+
+ def test_models_course_get_equivalent_course_run_dates_with_no_course_runs(self):
+ """
+ Check that course dates are processed
+ by aggregating target course runs dates as expected. If no course runs are found
+ the method should return None for all dates.
+ """
+ course = factories.CourseFactory()
+
+ self.assertEqual(
+ course.get_equivalent_course_run_dates(),
+ {
+ "start": None,
+ "end": None,
+ "enrollment_start": None,
+ "enrollment_end": None,
+ },
+ )
diff --git a/src/backend/joanie/tests/core/test_models_enrollment.py b/src/backend/joanie/tests/core/test_models_enrollment.py
index 1db13d880..7f838c3cf 100644
--- a/src/backend/joanie/tests/core/test_models_enrollment.py
+++ b/src/backend/joanie/tests/core/test_models_enrollment.py
@@ -10,7 +10,7 @@
from django.test.utils import override_settings
from django.utils import timezone
-from joanie.core import factories
+from joanie.core import enums, factories
from joanie.core.exceptions import EnrollmentError, GradeError
from joanie.core.models import CourseState, Enrollment
from joanie.lms_handler.backends.moodle import MoodleLMSBackend
@@ -262,7 +262,7 @@ def test_models_enrollment_allows_for_non_listed_course_run_with_product(
# - Once the product purchased, enrollment should be allowed
order = factories.OrderFactory(owner=user, product=product)
- order.submit()
+ order.init_flow()
factories.EnrollmentFactory(
course_run=course_run, user=user, was_created_by_order=True
)
@@ -345,7 +345,7 @@ def test_models_enrollment_forbid_for_non_listed_course_run_not_included_in_prod
course_relation.course_runs.set([cr1, cr2])
order = factories.OrderFactory(owner=user, product=product)
- order.submit()
+ order.init_flow()
# - Enroll to cr2 should fail
with self.assertRaises(ValidationError) as context:
@@ -518,7 +518,7 @@ def test_models_enrollment_was_created_by_order_flag(self, _mock_set):
# Then if user purchases the product, the flag should not have been updated
order = factories.OrderFactory(owner=user, product=product)
- order.submit()
+ order.init_flow()
order_enrollment = order.get_target_enrollments().first()
self.assertEqual(enrollment, order_enrollment)
self.assertFalse(order_enrollment.was_created_by_order)
@@ -550,7 +550,7 @@ def test_models_enrollment_was_created_by_order_flag_moodle(self, _mock_set):
# Then if user purchases the product, the flag should not have been updated
order = factories.OrderFactory(owner=user, product=product)
- order.submit()
+ order.init_flow()
order_enrollment = order.get_target_enrollments().first()
self.assertEqual(enrollment, order_enrollment)
self.assertFalse(order_enrollment.was_created_by_order)
@@ -638,12 +638,11 @@ def test_models_enrollment_forbid_for_listed_course_run_linked_to_product_requir
course=relation.course,
organization=relation.organizations.first(),
)
- order.submit()
-
factories.ContractFactory(
order=order,
definition=product.contract_definition,
)
+ order.init_flow()
with self.assertRaises(ValidationError) as context:
factories.EnrollmentFactory(
@@ -664,12 +663,23 @@ def test_models_enrollment_forbid_for_listed_course_run_linked_to_product_requir
# - Recreate a signed contract for the order
order.contract.delete()
+ # Generates a contract for the order
+ # Defining the student signature allows to create needed objects for further enrollment
factories.ContractFactory(
order=order,
definition=product.contract_definition,
submitted_for_signature_on=timezone.now(),
student_signed_on=timezone.now(),
)
+ # Sets the order to SIGNING state by removing the student signature
+ order.contract.student_signed_on = None
+ order.flow.update()
+ self.assertEqual(order.state, enums.ORDER_STATE_SIGNING)
+
+ # Sets back the student signature
+ order.contract.student_signed_on = timezone.now()
+ order.flow.update()
+ self.assertEqual(order.state, enums.ORDER_STATE_COMPLETED)
# - Now the enrollment should be allowed
factories.EnrollmentFactory(
@@ -680,3 +690,4 @@ def test_models_enrollment_forbid_for_listed_course_run_linked_to_product_requir
)
self.assertEqual(order.owner.enrollments.count(), 1)
+ self.assertEqual(order.state, enums.ORDER_STATE_COMPLETED)
diff --git a/src/backend/joanie/tests/core/test_models_order.py b/src/backend/joanie/tests/core/test_models_order.py
index f5afc7323..f949a8375 100644
--- a/src/backend/joanie/tests/core/test_models_order.py
+++ b/src/backend/joanie/tests/core/test_models_order.py
@@ -4,7 +4,6 @@
# pylint: disable=too-many-lines,too-many-public-methods
import json
-import random
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from unittest import mock
@@ -12,14 +11,19 @@
from django.contrib.sites.models import Site
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.serializers.json import DjangoJSONEncoder
-from django.test import TestCase
-from django.test.utils import override_settings
+from django.test import TestCase, override_settings
from django.utils import timezone as django_timezone
from joanie.core import enums, factories
+from joanie.core.factories import CourseRunFactory
from joanie.core.models import Contract, CourseState
from joanie.core.utils import contract_definition
-from joanie.payment.factories import BillingAddressDictFactory, InvoiceFactory
+from joanie.payment.factories import (
+ BillingAddressDictFactory,
+ CreditCardFactory,
+ InvoiceFactory,
+)
+from joanie.signature.backends import get_signature_backend
from joanie.tests.base import BaseLogMixinTestCase
@@ -60,19 +64,19 @@ def test_models_order_enrollment_was_created_by_order(self):
),
)
- def test_models_order_state_property_validated_when_free(self):
+ def test_models_order_state_property_completed_when_free(self):
"""
When an order relies on a free product, its state should be automatically
- validated without any invoice and without calling the validate()
+ completed without any invoice and without calling the assign()
method.
"""
courses = factories.CourseFactory.create_batch(2)
# Create a free product
product = factories.ProductFactory(courses=courses, price=0)
order = factories.OrderFactory(product=product, total=0.00)
- order.submit()
+ order.init_flow()
- self.assertEqual(order.state, enums.ORDER_STATE_VALIDATED)
+ self.assertEqual(order.state, enums.ORDER_STATE_COMPLETED)
def test_models_order_enrollment_owned_by_enrollment_user(self):
"""The enrollment linked to an order, must belong to the order owner."""
@@ -295,14 +299,16 @@ def test_models_order_course_owner_product_unique_canceled():
factories.OrderFactory(owner=order.owner, product=product, course=order.course)
- def test_models_order_course_runs_relation_sorted_by_position(self):
+ def test_models_order_freeze_target_courses_course_runs_relation_sorted_by_position(
+ self,
+ ):
"""The product/course relation should be sorted by position."""
courses = factories.CourseFactory.create_batch(5)
product = factories.ProductFactory(target_courses=courses)
# Create an order link to the product
order = factories.OrderFactory(product=product)
- order.submit(billing_address=BillingAddressDictFactory())
+ order.freeze_target_courses()
target_courses = order.target_courses.order_by("product_target_relations")
self.assertCountEqual(target_courses, courses)
@@ -360,14 +366,22 @@ def test_models_order_state_property(self):
course = factories.CourseFactory()
product = factories.ProductFactory(title="Traçabilité", courses=[course])
order = factories.OrderFactory(
- product=product, state=enums.ORDER_STATE_SUBMITTED
+ product=product,
+ state=enums.ORDER_STATE_ASSIGNED,
+ payment_schedule=[
+ {
+ "amount": "200.00",
+ "due_date": "2024-01-17",
+ "state": enums.PAYMENT_STATE_PAID,
+ }
+ ],
)
- # 2 - When an invoice is linked to the order, and the method validate() is
- # called its state is `validated`
+ # 2 - When an invoice is linked to the order, and the method complete() is
+ # called its state is `completed`
InvoiceFactory(order=order, total=order.total)
- order.flow.validate()
- self.assertEqual(order.state, enums.ORDER_STATE_VALIDATED)
+ order.flow.complete()
+ self.assertEqual(order.state, enums.ORDER_STATE_COMPLETED)
# 3 - When order is canceled, its state is `canceled`
order.flow.cancel()
@@ -387,7 +401,7 @@ def test_models_order_get_target_enrollments(self):
price="0.00", target_courses=[cr1.course, cr2.course]
)
order = factories.OrderFactory(product=product)
- order.submit()
+ order.init_flow()
# - As the two product's target courses have only one course run, order owner
# should have been automatically enrolled to those course runs.
@@ -402,6 +416,30 @@ def test_models_order_get_target_enrollments(self):
self.assertEqual(len(order.get_target_enrollments(is_active=True)), 0)
self.assertEqual(len(order.get_target_enrollments(is_active=False)), 2)
+ def test_models_order_get_target_enrollments_for_certificate_product(self):
+ """
+ Order model implements a `get_target_enrollments` method to retrieve enrollments
+ related to the order instance.
+ """
+ enrollment = factories.EnrollmentFactory(is_active=True)
+ product = factories.ProductFactory(
+ price="0.00",
+ type=enums.PRODUCT_TYPE_CERTIFICATE,
+ courses=[enrollment.course_run.course],
+ target_courses=[],
+ )
+ order = factories.OrderFactory(
+ product=product, enrollment=enrollment, course=None
+ )
+ order.init_flow()
+
+ # - As the two product's target courses have only one course run, order owner
+ # should have been automatically enrolled to those course runs.
+ with self.assertNumQueries(1):
+ self.assertEqual(len(order.get_target_enrollments()), 1)
+ self.assertEqual(len(order.get_target_enrollments(is_active=True)), 1)
+ self.assertEqual(len(order.get_target_enrollments(is_active=False)), 0)
+
def test_models_order_target_course_runs_property(self):
"""
Order model has a target course runs property to retrieve all course runs
@@ -410,7 +448,7 @@ def test_models_order_target_course_runs_property(self):
[course1, course2] = factories.CourseFactory.create_batch(2)
[cr1, cr2] = factories.CourseRunFactory.create_batch(2, course=course1)
[cr3, cr4] = factories.CourseRunFactory.create_batch(2, course=course2)
- product = factories.ProductFactory(target_courses=[course1, course2])
+ product = factories.ProductFactory(target_courses=[course1, course2], price=0)
# - Link cr3 to the product course relations
relation = product.target_course_relations.get(course=course2)
@@ -418,7 +456,7 @@ def test_models_order_target_course_runs_property(self):
# - Create an order link to the product
order = factories.OrderFactory(product=product)
- order.submit(billing_address=BillingAddressDictFactory())
+ order.init_flow()
# - Update product course relation, order course relation should not be impacted
relation.course_runs.set([])
@@ -437,53 +475,51 @@ def test_models_order_target_course_runs_property(self):
self.assertEqual(len(course_runs), 3)
self.assertCountEqual(list(course_runs), [cr1, cr2, cr3])
- def test_models_order_create_target_course_relations_on_submit(self):
+ def test_models_order_target_course_runs_property_linked_to_enrollment(self):
"""
- When an order is submitted, product target courses should be copied to the order
+ Order model has a target course runs property to retrieve all course runs
+ related to the order instance. If the order is included to an enrollment,
+ the target course runs should be the same as the enrollment's course run.
"""
+ user = factories.UserFactory()
+ enrollment = factories.EnrollmentFactory(user=user)
product = factories.ProductFactory(
- target_courses=factories.CourseFactory.create_batch(2)
+ price=0,
+ type=enums.PRODUCT_TYPE_CERTIFICATE,
+ courses=[enrollment.course_run.course],
)
- order = factories.OrderFactory(product=product)
- self.assertEqual(order.state, enums.ORDER_STATE_DRAFT)
- self.assertEqual(order.target_courses.count(), 0)
-
- # Then we submit the order
- order.submit(billing_address=BillingAddressDictFactory())
+ # - Create an order link to the product
+ order = factories.OrderFactory(
+ product=product, enrollment=enrollment, course=None, owner=user
+ )
+ order.init_flow()
- self.assertEqual(order.state, enums.ORDER_STATE_SUBMITTED)
- self.assertEqual(order.target_courses.count(), 2)
+ # - DB queries should be optimized
+ with self.assertNumQueries(1):
+ course_runs = order.target_course_runs
+ self.assertEqual(len(course_runs), 1)
+ self.assertEqual(course_runs[0], enrollment.course_run)
- def test_models_order_dont_create_target_course_relations_on_resubmit(self):
+ def test_models_order_create_target_course_relations_on_submit(self):
"""
- When an order is submitted again, product target courses should not be copied
- again to the order
+ When an order is submitted, product target courses should be copied to the order
"""
product = factories.ProductFactory(
- target_courses=factories.CourseFactory.create_batch(2)
+ target_courses=factories.CourseFactory.create_batch(
+ 2, course_runs=[CourseRunFactory()]
+ ),
)
order = factories.OrderFactory(product=product)
self.assertEqual(order.state, enums.ORDER_STATE_DRAFT)
self.assertEqual(order.target_courses.count(), 0)
- # Then we submit the order
- order.submit(billing_address=BillingAddressDictFactory())
-
- self.assertEqual(order.state, enums.ORDER_STATE_SUBMITTED)
- self.assertEqual(order.target_courses.count(), 2)
-
- # Unfortunately, order transitions to pending state
- order.flow.pending()
+ # Then we launch the order flow
+ order.init_flow(billing_address=BillingAddressDictFactory())
self.assertEqual(order.state, enums.ORDER_STATE_PENDING)
-
- # So we need to submit it again
- order.submit(billing_address=BillingAddressDictFactory())
-
- self.assertEqual(order.state, enums.ORDER_STATE_SUBMITTED)
- self.assertEqual(order.target_courses.count(), product.target_courses.count())
+ self.assertEqual(order.target_courses.count(), 2)
@mock.patch(
"joanie.signature.backends.dummy.DummySignatureBackend.submit_for_signature",
@@ -497,14 +533,9 @@ def test_models_order_submit_for_signature_document_title(
to the signature backend according to the current date, the related
course and the order pk.
"""
- user = factories.UserFactory()
- order = factories.OrderFactory(
- owner=user,
- state=enums.ORDER_STATE_VALIDATED,
- product__contract_definition=factories.ContractDefinitionFactory(),
- )
+ order = factories.OrderGeneratorFactory(state=enums.ORDER_STATE_TO_SIGN)
- order.submit_for_signature(user=user)
+ order.submit_for_signature(user=order.owner)
now = django_timezone.now()
_mock_submit_for_signature.assert_called_once()
@@ -554,52 +585,45 @@ def test_models_order_submit_for_signature_fails_when_the_product_has_no_contrac
],
)
- def test_models_order_submit_for_signature_fails_because_order_is_not_state_validate(
+ def test_models_order_submit_for_signature_fails_because_order_is_not_to_sign(
self,
):
"""
- When the order is not in state 'validated', it should not be possible to submit for
- signature.
+ When the order is not in state 'to sign' or 'to sign and to save payment method',
+ it should not be possible to submit for signature.
"""
user = factories.UserFactory()
factories.UserAddressFactory(owner=user)
- order = factories.OrderFactory(
- owner=user,
- state=random.choice(
- [
- enums.ORDER_STATE_CANCELED,
- enums.ORDER_STATE_SUBMITTED,
- enums.ORDER_STATE_DRAFT,
- enums.ORDER_STATE_PENDING,
- ]
- ),
- product__contract_definition=factories.ContractDefinitionFactory(),
- )
-
- with (
- self.assertRaises(ValidationError) as context,
- self.assertLogs("joanie") as logger,
- ):
- order.submit_for_signature(user=user)
-
- self.assertEqual(
- str(context.exception),
- "['Cannot submit an order that is not yet validated.']",
- )
-
- self.assertLogsEquals(
- logger.records,
- [
- (
- "ERROR",
- "Cannot submit an order that is not yet validated.",
- {"order": dict},
- ),
- ],
- )
+ for state, _ in enums.ORDER_STATE_CHOICES:
+ with self.subTest(state=state):
+ order = factories.OrderGeneratorFactory(owner=user, state=state)
+
+ if state in [enums.ORDER_STATE_TO_SIGN, enums.ORDER_STATE_SIGNING]:
+ order.submit_for_signature(user=user)
+ else:
+ with (
+ self.assertRaises(ValidationError) as context,
+ self.assertLogs("joanie") as logger,
+ ):
+ order.submit_for_signature(user=user)
+
+ if state in [enums.ORDER_STATE_DRAFT, enums.ORDER_STATE_ASSIGNED]:
+ error_message = (
+ "No contract definition attached to the contract's product."
+ )
+ error_context = {"order": dict, "product": dict}
+ else:
+ error_message = "Cannot submit an order that is not to sign."
+ error_context = {"order": dict}
+
+ self.assertEqual(str(context.exception), str([error_message]))
+ self.assertLogsEquals(
+ logger.records, [("ERROR", error_message, error_context)]
+ )
+ @mock.patch("joanie.core.utils.issuers.generate_document")
def test_models_order_submit_for_signature_with_a_brand_new_contract(
- self,
+ self, mock_generate_document
):
"""
When the order's product has a contract definition, and the order doesn't have yet
@@ -608,26 +632,34 @@ def test_models_order_submit_for_signature_with_a_brand_new_contract(
'submitted_for_signature_on', 'context', 'definition_checksum',
'signature_backend_reference'.
"""
- user = factories.UserFactory()
- order = factories.OrderFactory(
- owner=user,
- state=enums.ORDER_STATE_VALIDATED,
- product__contract_definition=factories.ContractDefinitionFactory(),
+ order = factories.OrderGeneratorFactory(
+ state=enums.ORDER_STATE_TO_SIGN, contract=None
)
- raw_invitation_link = order.submit_for_signature(user=user)
+ raw_invitation_link = order.submit_for_signature(user=order.owner)
order.contract.refresh_from_db()
self.assertIsNotNone(order.contract)
- self.assertIsNotNone(order.contract.student_signed_on)
+ self.assertIsNone(order.contract.student_signed_on)
self.assertIsNotNone(order.contract.submitted_for_signature_on)
self.assertIsNotNone(order.contract.context)
self.assertIsNotNone(order.contract.definition)
self.assertIsNotNone(order.contract.signature_backend_reference)
self.assertIsNotNone(order.contract.definition_checksum)
self.assertIn(
- "https://dummysignaturebackend.fr/?requestToken=", raw_invitation_link
+ "https://dummysignaturebackend.fr/?reference=", raw_invitation_link
)
+ context_with_images = mock_generate_document.call_args.kwargs["context"]
+ organization_logo = context_with_images["organization"]["logo"]
+ self.assertIn("data:image/png;base64,", organization_logo)
+ self.assertNotIn("logo_id", context_with_images["organization"])
+
+ backend = get_signature_backend()
+ backend.confirm_student_signature(
+ reference=order.contract.signature_backend_reference
+ )
+ order.refresh_from_db()
+ self.assertIsNotNone(order.contract.student_signed_on)
def test_models_order_submit_for_signature_existing_contract_with_same_context_and_still_valid(
self,
@@ -639,27 +671,23 @@ def test_models_order_submit_for_signature_existing_contract_with_same_context_a
'submitted_for_signature_on', 'context', 'definition_checksum',
'signature_backend_reference' of the contract.
"""
- user = factories.UserFactory()
- order = factories.OrderFactory(
- owner=user,
- state=enums.ORDER_STATE_VALIDATED,
- product__contract_definition=factories.ContractDefinitionFactory(),
- )
+ order = factories.OrderGeneratorFactory(
+ state=enums.ORDER_STATE_SIGNING,
+ contract__signature_backend_reference="wfl_fake_dummy_id_1",
+ contract__definition_checksum="fake_dummy_file_hash_1",
+ contract__context="content",
+ contract__submitted_for_signature_on=django_timezone.now(),
+ )
+ contract = order.contract
context = contract_definition.generate_document_context(
contract_definition=order.product.contract_definition,
- user=user,
- order=order,
- )
- contract = factories.ContractFactory(
+ user=order.owner,
order=order,
- definition=order.product.contract_definition,
- signature_backend_reference="wfl_fake_dummy_id_1",
- definition_checksum="fake_dummy_file_hash_1",
- context=context,
- submitted_for_signature_on=django_timezone.now(),
)
+ contract.context = context
+ contract.save()
- invitation_url = order.submit_for_signature(user=user)
+ invitation_url = order.submit_for_signature(user=order.owner)
contract.refresh_from_db()
self.assertEqual(
@@ -670,7 +698,7 @@ def test_models_order_submit_for_signature_existing_contract_with_same_context_a
contract.signature_backend_reference,
"wfl_fake_dummy_id_1",
)
- self.assertIn("https://dummysignaturebackend.fr/?requestToken=", invitation_url)
+ self.assertIn("https://dummysignaturebackend.fr/?reference=", invitation_url)
def test_models_order_submit_for_signature_with_contract_context_has_changed_and_still_valid(
self,
@@ -682,29 +710,30 @@ def test_models_order_submit_for_signature_with_contract_context_has_changed_and
'submitted_for_signature_on', 'context', 'definition_checksum',
'signature_backend_reference'
"""
- user = factories.UserFactory()
- order = factories.OrderFactory(
- owner=user,
- state=enums.ORDER_STATE_VALIDATED,
- product__contract_definition=factories.ContractDefinitionFactory(),
- )
- contract = factories.ContractFactory(
- order=order,
- definition=order.product.contract_definition,
- signature_backend_reference="wfl_fake_dummy_id_123",
- definition_checksum="fake_test_file_hash_1",
- context="content",
- submitted_for_signature_on=django_timezone.now(),
+ order = factories.OrderGeneratorFactory(
+ state=enums.ORDER_STATE_SIGNING,
+ contract__signature_backend_reference="wfl_fake_dummy_id_123",
+ contract__definition_checksum="fake_test_file_hash_1",
+ contract__context="content",
+ contract__submitted_for_signature_on=django_timezone.now(),
)
+ contract = order.contract
- invitation_url = order.submit_for_signature(user=user)
+ invitation_url = order.submit_for_signature(user=order.owner)
contract.refresh_from_db()
- self.assertIn("https://dummysignaturebackend.fr/?requestToken=", invitation_url)
+ self.assertIn("https://dummysignaturebackend.fr/?reference=", invitation_url)
self.assertIn("wfl_fake_dummy_", contract.signature_backend_reference)
self.assertIn("fake_dummy_file_hash", contract.definition_checksum)
self.assertIsNotNone(contract.submitted_for_signature_on)
- self.assertIsNotNone(contract.student_signed_on)
+ self.assertIsNone(contract.student_signed_on)
+
+ backend = get_signature_backend()
+ backend.confirm_student_signature(
+ reference=order.contract.signature_backend_reference
+ )
+ order.refresh_from_db()
+ self.assertIsNotNone(order.contract.student_signed_on)
@override_settings(
JOANIE_SIGNATURE_VALIDITY_PERIOD_IN_SECONDS=60 * 60 * 24 * 15,
@@ -721,16 +750,17 @@ def test_models_order_submit_for_signature_contract_same_context_but_passed_vali
"""
user = factories.UserFactory()
order = factories.OrderFactory(
+ state=enums.ORDER_STATE_TO_SIGN,
owner=user,
- state=enums.ORDER_STATE_VALIDATED,
product__contract_definition=factories.ContractDefinitionFactory(),
product__target_courses=[
factories.CourseFactory.create(
course_runs=[
factories.CourseRunFactory(state=CourseState.ONGOING_OPEN)
- ]
+ ],
)
],
+ main_invoice=InvoiceFactory(),
)
context = contract_definition.generate_document_context(
contract_definition=order.product.contract_definition,
@@ -745,21 +775,20 @@ def test_models_order_submit_for_signature_contract_same_context_but_passed_vali
context=context,
submitted_for_signature_on=django_timezone.now() - timedelta(days=16),
)
+ order.flow.update()
with self.assertLogs("joanie") as logger:
invitation_url = order.submit_for_signature(user=user)
- enrollment = user.enrollments.first()
-
contract.refresh_from_db()
self.assertEqual(
contract.context, json.loads(DjangoJSONEncoder().encode(context))
)
- self.assertIn("https://dummysignaturebackend.fr/?requestToken=", invitation_url)
+ self.assertIn("https://dummysignaturebackend.fr/?reference=", invitation_url)
self.assertIn("fake_dummy_file_hash", contract.definition_checksum)
self.assertNotEqual("wfl_fake_dummy_id_1", contract.signature_backend_reference)
self.assertIsNotNone(contract.submitted_for_signature_on)
- self.assertIsNotNone(contract.student_signed_on)
+ self.assertIsNone(contract.student_signed_on)
self.assertLogsEquals(
logger.records,
[
@@ -777,16 +806,6 @@ def test_models_order_submit_for_signature_contract_same_context_but_passed_vali
"INFO",
f"Document signature refused for the contract '{contract.id}'",
),
- (
- "INFO",
- f"Active Enrollment {enrollment.pk} has been created",
- ),
- ("INFO", f"Student signed the contract '{contract.id}'"),
- (
- "INFO",
- f"Mail for '{contract.signature_backend_reference}' "
- f"is sent from Dummy Signature Backend",
- ),
],
)
@@ -796,13 +815,23 @@ def test_models_order_submit_for_signature_but_contract_is_already_signed_should
"""
When an order already have his contract signed, it should raise an error because
we cannot submit it again.
+
+ This case could not happen anymore with the new flow.
"""
user = factories.UserFactory()
factories.UserAddressFactory(owner=user)
order = factories.OrderFactory(
owner=user,
- state=enums.ORDER_STATE_VALIDATED,
+ # order is signed by the student, but the state is not updated accordingly
+ state=enums.ORDER_STATE_TO_SIGN,
product__contract_definition=factories.ContractDefinitionFactory(),
+ payment_schedule=[
+ {
+ "amount": "200.00",
+ "due_date": "2024-01-17",
+ "state": enums.PAYMENT_STATE_PAID,
+ }
+ ],
)
now = django_timezone.now()
factories.ContractFactory(
@@ -908,12 +937,12 @@ def test_models_order_target_course_runs_property_distinct(self):
[o0, *_] = factories.OrderFactory.create_batch(
5,
product=p0,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
[o1, *_] = factories.OrderFactory.create_batch(
5,
product=p1,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
self.assertEqual(o0.target_course_runs.count(), 1)
@@ -964,9 +993,18 @@ def test_models_order_submit_for_signature_check_contract_context_course_section
owner=user,
product=relation.product,
course=relation.course,
- state=enums.ORDER_STATE_VALIDATED,
- main_invoice=InvoiceFactory(recipient_address=user_address),
+ payment_schedule=[
+ {
+ "amount": "200.00",
+ "due_date": "2024-01-17",
+ "state": enums.PAYMENT_STATE_PAID,
+ }
+ ],
)
+ factories.ContractFactory(order=order)
+ billing_address = user_address.to_dict()
+ billing_address.pop("owner")
+ order.init_flow(billing_address=billing_address)
factories.OrderTargetCourseRelationFactory(
course=relation.course, order=order, position=1
)
@@ -1008,6 +1046,148 @@ def test_models_order_submit_for_signature_check_contract_context_course_section
self.assertEqual(order.total, Decimal("1202.99"))
self.assertEqual(contract.context["course"]["price"], "1202.99")
+ def test_models_order_is_free(self):
+ """
+ Check that the `is_free` property returns True if the order total is 0.
+ """
+ order = factories.OrderFactory(product__price=0)
+ self.assertTrue(order.is_free)
+
+ def test_models_order_is_free_product_price(self):
+ """
+ Check that the `is_free` property returns False if the order total is not 0.
+ """
+ order = factories.OrderFactory(product__price=1)
+ self.assertFalse(order.is_free)
+
+ def test_models_order_has_payment_method(self):
+ """
+ Check that the `has_payment_method` property returns True if the order owner credit
+ card has an initial issuer transaction identifier.
+ """
+ credit_card = CreditCardFactory(
+ initial_issuer_transaction_identifier="4575676657929351"
+ )
+ order = factories.OrderFactory(owner=credit_card.owner)
+ self.assertTrue(order.has_payment_method)
+
+ def test_models_order_has_payment_method_no_transaction_identifier(self):
+ """
+ Check that the `has_payment_method` property returns False if the order owner credit
+ card has no initial issuer transaction identifier.
+ """
+ order = factories.OrderFactory(
+ credit_card=CreditCardFactory(initial_issuer_transaction_identifier=None)
+ )
+ self.assertFalse(order.has_payment_method)
+
+ def test_models_order_has_submitted_contract(self):
+ """
+ Check that the `has_submitted_contract` property returns True if the order has a
+ submitted contract.
+ """
+ order = factories.OrderFactory()
+ factories.ContractFactory(
+ order=order,
+ definition=factories.ContractDefinitionFactory(),
+ submitted_for_signature_on=datetime(2023, 9, 20, 8, 0, tzinfo=timezone.utc),
+ )
+ self.assertTrue(order.has_submitted_contract)
+
+ def test_models_order_has_submitted_contract_not_submitted(self):
+ """
+ Check that the `has_submitted_contract` property returns True if the order has a
+ submitted contract.
+ """
+ order = factories.OrderFactory()
+ factories.ContractFactory(
+ order=order,
+ definition=factories.ContractDefinitionFactory(),
+ )
+ self.assertFalse(order.has_submitted_contract)
+
+ def test_models_order_has_submitted_contract_no_contract(self):
+ """
+ Check that the `has_submitted_contract` property returns True if the order has a
+ submitted contract.
+ """
+ order = factories.OrderFactory()
+ self.assertFalse(order.has_submitted_contract)
+
+ def test_models_order_has_unsigned_contract(self):
+ """
+ Check that the `has_unsigned_contract` property returns True
+ if the order's contract is not signed by student.
+ """
+ order = factories.OrderFactory()
+ factories.ContractFactory(
+ order=order,
+ definition=factories.ContractDefinitionFactory(),
+ )
+ self.assertTrue(order.has_unsigned_contract)
+
+ def test_models_order_has_unsigned_contract_no_contract(self):
+ """
+ Check that the `has_unsigned_contract` property returns False if the order has no contract.
+ """
+ order = factories.OrderFactory()
+ self.assertFalse(order.has_unsigned_contract)
+
+ def test_models_order_has_unsigned_contract_no_signature(self):
+ """
+ Check that the `has_unsigned_contract` property returns True
+ if the order has an unsigned contract.
+ """
+ order = factories.OrderFactory()
+ factories.ContractFactory(
+ order=order,
+ definition=factories.ContractDefinitionFactory(),
+ )
+ self.assertTrue(order.has_unsigned_contract)
+
+ def test_models_order_has_unsigned_contract_signature(self):
+ """
+ Check that the `has_unsigned_contract` property returns False
+ if the order has a signed contract.
+ """
+ order = factories.OrderFactory()
+ factories.ContractFactory(
+ order=order,
+ definition=factories.ContractDefinitionFactory(),
+ student_signed_on=datetime(2023, 9, 20, 8, 0, tzinfo=timezone.utc),
+ submitted_for_signature_on=datetime(2023, 9, 20, 8, 0, tzinfo=timezone.utc),
+ )
+ self.assertFalse(order.has_unsigned_contract)
+
+ def test_models_order_has_unsigned_contract_product_contract_definition(self):
+ """
+ Check that the `has_unsigned_contract` property returns True
+ if the order's contract is not signed by student.
+ """
+ order = factories.OrderFactory(
+ product__contract_definition=factories.ContractDefinitionFactory()
+ )
+ self.assertTrue(order.has_unsigned_contract)
+ with self.assertRaises(Contract.DoesNotExist):
+ order.contract # pylint: disable=pointless-statement
+
+ def test_models_order_has_consent_to_terms_should_raise_deprecation_warning(self):
+ """
+ Due to the refactoring of `has_consent_to_terms` attribute, it is now a deprecated field.
+ So when calling the field, it should raise a `DeprecationWarning` error.
+ """
+ order = factories.OrderFactory()
+
+ with self.assertRaises(DeprecationWarning) as deprecation_warning:
+ # ruff : noqa : B018
+ # pylint: disable=pointless-statement
+ order.has_consent_to_terms
+
+ self.assertEqual(
+ str(deprecation_warning.exception),
+ "Access denied to has_consent_to_terms: deprecated field",
+ )
+
def test_models_order_avoid_to_create_with_an_archived_course_run(self):
"""
An order cannot be generated if the course run is archived. It should raise a
@@ -1031,7 +1211,7 @@ def test_models_order_avoid_to_create_with_an_archived_course_run(self):
course=None,
product__type=enums.PRODUCT_TYPE_CERTIFICATE,
product__courses=[enrollment.course_run.course],
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
self.assertEqual(
@@ -1056,7 +1236,7 @@ def test_api_order_allow_to_cancel_with_archived_course_run(self):
course=None,
product__type=enums.PRODUCT_TYPE_CERTIFICATE,
product__courses=[enrollment.course_run.course],
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
# Update the course run to archived it
diff --git a/src/backend/joanie/tests/core/test_models_order_enroll_user_to_course_run.py b/src/backend/joanie/tests/core/test_models_order_enroll_user_to_course_run.py
index a23fda598..49489fcdf 100644
--- a/src/backend/joanie/tests/core/test_models_order_enroll_user_to_course_run.py
+++ b/src/backend/joanie/tests/core/test_models_order_enroll_user_to_course_run.py
@@ -8,7 +8,6 @@
from joanie.core import enums, factories
from joanie.core.models import CourseState, Enrollment
-from joanie.payment.factories import BillingAddressDictFactory, InvoiceFactory
# pylint: disable=too-many-public-methods
@@ -16,19 +15,14 @@ class EnrollUserToCourseRunOrderModelsTestCase(TestCase):
"""Test suite for `enroll_user_to_course_run` method on the Order model."""
def _create_validated_order(self, **kwargs):
+ kwargs["product"].price = 0
order = factories.OrderFactory(**kwargs)
- order.submit(billing_address=BillingAddressDictFactory())
-
- self.assertEqual(order.state, enums.ORDER_STATE_SUBMITTED)
self.assertEqual(Enrollment.objects.count(), 0)
- # - Create an invoice to mark order as validated
- InvoiceFactory(order=order, total=order.total)
-
- # - Validate the order should automatically enroll user to course run
- order.flow.validate()
+ # - Completing the order should automatically enroll user to course run
+ order.init_flow()
- self.assertEqual(order.state, enums.ORDER_STATE_VALIDATED)
+ self.assertEqual(order.state, enums.ORDER_STATE_COMPLETED)
def test_models_order_enroll_user_to_course_run_one_open(self):
"""
diff --git a/src/backend/joanie/tests/core/test_models_order_get_or_generate_certificate_for_credential_product.py b/src/backend/joanie/tests/core/test_models_order_get_or_generate_certificate_for_credential_product.py
index 317d8c261..054e702f8 100644
--- a/src/backend/joanie/tests/core/test_models_order_get_or_generate_certificate_for_credential_product.py
+++ b/src/backend/joanie/tests/core/test_models_order_get_or_generate_certificate_for_credential_product.py
@@ -41,7 +41,7 @@ def test_models_order_get_or_generate_certificate_for_credential_product_success
],
)
order = factories.OrderFactory(product=product)
- order.submit()
+ order.init_flow()
new_certificate, created = order.get_or_generate_certificate()
@@ -205,7 +205,7 @@ def test_models_order_get_or_generate_certificate_for_credential_product_enrollm
target_courses=[course_run.course],
)
order = factories.OrderFactory(product=product)
- order.submit()
+ order.init_flow()
enrollment = Enrollment.objects.get()
enrollment.is_active = False
enrollment.save()
diff --git a/src/backend/joanie/tests/core/test_models_organization.py b/src/backend/joanie/tests/core/test_models_organization.py
index 0700c2d37..5c880fe05 100644
--- a/src/backend/joanie/tests/core/test_models_organization.py
+++ b/src/backend/joanie/tests/core/test_models_organization.py
@@ -148,6 +148,42 @@ def test_models_organization_get_abilities_preset_role(self):
},
)
+ def test_models_organization_signature_backend_references_to_sign_states(self):
+ """Every contract with order state other than canceled should be returned."""
+ for state, _ in enums.ORDER_STATE_CHOICES:
+ with self.subTest(state=state):
+ now = timezone.now()
+ organization = factories.OrganizationFactory()
+ relation = factories.CourseProductRelationFactory(
+ organizations=[organization],
+ product__contract_definition=factories.ContractDefinitionFactory(),
+ )
+ contract = factories.ContractFactory(
+ order__state=state,
+ order__product=relation.product,
+ order__course=relation.course,
+ order__organization=organization,
+ signature_backend_reference=factory.Sequence(
+ lambda n: f"wfl_fake_dummy_id_{n!s}"
+ ),
+ submitted_for_signature_on=now,
+ student_signed_on=now,
+ )
+
+ if state == enums.ORDER_STATE_CANCELED:
+ self.assertEqual(
+ organization.signature_backend_references_to_sign(),
+ ((), ()),
+ )
+ else:
+ self.assertEqual(
+ organization.signature_backend_references_to_sign(),
+ (
+ (contract.id,),
+ (contract.signature_backend_reference,),
+ ),
+ )
+
def test_models_organization_signature_backend_references_to_sign(self):
"""Should return a list of references to sign."""
now = timezone.now()
@@ -163,7 +199,7 @@ def test_models_organization_signature_backend_references_to_sign(self):
for relation in relations:
contracts_to_sign.append(
factories.ContractFactory(
- order__state=enums.ORDER_STATE_VALIDATED,
+ order__state=enums.ORDER_STATE_COMPLETED,
order__product=relation.product,
order__course=relation.course,
order__organization=organization,
@@ -176,7 +212,7 @@ def test_models_organization_signature_backend_references_to_sign(self):
)
other_contracts.append(
factories.ContractFactory(
- order__state=enums.ORDER_STATE_VALIDATED,
+ order__state=enums.ORDER_STATE_COMPLETED,
order__product=relation.product,
order__course=relation.course,
order__organization=organization,
@@ -187,7 +223,7 @@ def test_models_organization_signature_backend_references_to_sign(self):
)
signed_contracts.append(
factories.ContractFactory(
- order__state=enums.ORDER_STATE_VALIDATED,
+ order__state=enums.ORDER_STATE_COMPLETED,
order__product=relation.product,
order__course=relation.course,
order__organization=organization,
@@ -228,7 +264,7 @@ def test_models_organization_signature_backend_references_to_sign_specified_ids(
for relation in relations:
contracts_to_sign.append(
factories.ContractFactory(
- order__state=enums.ORDER_STATE_VALIDATED,
+ order__state=enums.ORDER_STATE_COMPLETED,
order__product=relation.product,
order__course=relation.course,
order__organization=organization,
@@ -241,7 +277,7 @@ def test_models_organization_signature_backend_references_to_sign_specified_ids(
)
other_contracts.append(
factories.ContractFactory(
- order__state=enums.ORDER_STATE_VALIDATED,
+ order__state=enums.ORDER_STATE_COMPLETED,
order__product=relation.product,
order__course=relation.course,
order__organization=organization,
@@ -252,7 +288,7 @@ def test_models_organization_signature_backend_references_to_sign_specified_ids(
)
signed_contracts.append(
factories.ContractFactory(
- order__state=enums.ORDER_STATE_VALIDATED,
+ order__state=enums.ORDER_STATE_COMPLETED,
order__product=relation.product,
order__course=relation.course,
order__organization=organization,
@@ -298,7 +334,7 @@ def test_models_organization_signature_backend_references_to_sign_unknown_specif
for relation in relations:
contracts_to_sign.append(
factories.ContractFactory(
- order__state=enums.ORDER_STATE_VALIDATED,
+ order__state=enums.ORDER_STATE_COMPLETED,
order__product=relation.product,
order__course=relation.course,
order__organization=organization,
@@ -311,7 +347,7 @@ def test_models_organization_signature_backend_references_to_sign_unknown_specif
)
other_contracts.append(
factories.ContractFactory(
- order__state=enums.ORDER_STATE_VALIDATED,
+ order__state=enums.ORDER_STATE_COMPLETED,
order__product=relation.product,
order__course=relation.course,
order__organization=organization,
@@ -322,7 +358,7 @@ def test_models_organization_signature_backend_references_to_sign_unknown_specif
)
signed_contracts.append(
factories.ContractFactory(
- order__state=enums.ORDER_STATE_VALIDATED,
+ order__state=enums.ORDER_STATE_COMPLETED,
order__product=relation.product,
order__course=relation.course,
order__organization=organization,
@@ -366,7 +402,7 @@ def test_models_organization_contracts_signature_link(self):
contracts = []
for relation in relations:
contract = factories.ContractFactory(
- order__state=enums.ORDER_STATE_VALIDATED,
+ order__state=enums.ORDER_STATE_COMPLETED,
order__product=relation.product,
order__course=relation.course,
order__organization=organization,
@@ -382,7 +418,7 @@ def test_models_organization_contracts_signature_link(self):
(invitation_url, contract_ids) = organization.contracts_signature_link(
user=user
)
- self.assertIn("https://dummysignaturebackend.fr/?requestToken=", invitation_url)
+ self.assertIn("https://dummysignaturebackend.fr/?reference=", invitation_url)
contracts_to_sign_ids = [contract.id for contract in contracts]
self.assertCountEqual(contracts_to_sign_ids, contract_ids)
@@ -398,7 +434,7 @@ def test_models_organization_contracts_signature_link_specified_ids(self):
contracts = []
for relation in relations:
contract = factories.ContractFactory(
- order__state=enums.ORDER_STATE_VALIDATED,
+ order__state=enums.ORDER_STATE_COMPLETED,
order__product=relation.product,
order__course=relation.course,
order__organization=organization,
@@ -417,7 +453,7 @@ def test_models_organization_contracts_signature_link_specified_ids(self):
(invitation_url, contract_ids) = organization.contracts_signature_link(
user=user, contract_ids=contracts_to_sign_ids
)
- self.assertIn("https://dummysignaturebackend.fr/?requestToken=", invitation_url)
+ self.assertIn("https://dummysignaturebackend.fr/?reference=", invitation_url)
self.assertCountEqual(contract_ids, contracts_to_sign_ids)
def test_models_organization_contracts_signature_link_empty(self):
diff --git a/src/backend/joanie/tests/core/test_models_product.py b/src/backend/joanie/tests/core/test_models_product.py
index aecca271f..056811df6 100644
--- a/src/backend/joanie/tests/core/test_models_product.py
+++ b/src/backend/joanie/tests/core/test_models_product.py
@@ -46,10 +46,11 @@ def test_models_product_type_enrollment_no_certificate_definition(self):
def test_models_product_course_runs_unique(self):
"""A product can only be linked once to a given course run."""
relation = factories.ProductTargetCourseRelationFactory()
- with self.assertRaises(ValidationError):
- factories.ProductTargetCourseRelationFactory(
- course=relation.course, product=relation.product
- )
+ # As django_get_or_create is used, the same relation should be returned
+ other_relation = factories.ProductTargetCourseRelationFactory(
+ course=relation.course, product=relation.product
+ )
+ self.assertEqual(relation, other_relation)
def test_models_product_course_runs_relation_sorted_by_position(self):
"""The product/course relation should be sorted by position."""
diff --git a/src/backend/joanie/tests/core/test_models_site_config.py b/src/backend/joanie/tests/core/test_models_site_config.py
index 28a2d9817..e0ca420b8 100644
--- a/src/backend/joanie/tests/core/test_models_site_config.py
+++ b/src/backend/joanie/tests/core/test_models_site_config.py
@@ -19,50 +19,12 @@ def test_models_site_config_get_terms_and_conditions_in_html(self):
It should return the terms and conditions in html format in the current language
if it exists.
"""
- terms_markdown_english = """
- ## Terms and conditions
- Here are the terms and conditions of the current site.
- """
-
- terms_markdown_french = """
- ## Conditions générales de ventes
- Voici les conditions générales de ventes du site.
- """
-
site_config = SiteConfigFactory()
- # If no translation exists, it should return an empty string
- self.assertEqual(site_config.get_terms_and_conditions_in_html(), "")
-
- # Create default language and french translations of the terms and conditions
- site_config.translations.create(terms_and_conditions=terms_markdown_english)
- site_config.translations.create(
- language_code="fr", terms_and_conditions=terms_markdown_french
- )
-
- # It should use the default language if no language is provided
- self.assertEqual(
- site_config.get_terms_and_conditions_in_html(),
- (
- "
Terms and conditions
\n"
- "
Here are the terms and conditions of the current site.
"
- ),
- )
-
- # It should use the provided language if it exists
- self.assertEqual(
- site_config.get_terms_and_conditions_in_html("fr"),
- (
- "
Conditions générales de ventes
\n"
- "
Voici les conditions générales de ventes du site.
"
- ),
- )
+ with self.assertRaises(DeprecationWarning) as deprecation_warning:
+ site_config.get_terms_and_conditions_in_html()
- # It should fallback to the default language if the provided language does not exist
self.assertEqual(
- site_config.get_terms_and_conditions_in_html("de"),
- (
- "
Terms and conditions
\n"
- "
Here are the terms and conditions of the current site.
"
- ),
+ str(deprecation_warning.exception),
+ "Terms and conditions are managed through contract definition body.",
)
diff --git a/src/backend/joanie/tests/core/test_templatetags_extra_tags.py b/src/backend/joanie/tests/core/test_templatetags_extra_tags.py
index 9ee64bfd3..567f400a6 100644
--- a/src/backend/joanie/tests/core/test_templatetags_extra_tags.py
+++ b/src/backend/joanie/tests/core/test_templatetags_extra_tags.py
@@ -4,10 +4,13 @@
import random
-from django.test import TestCase
+from django.test import TestCase, override_settings
+
+from stockholm import Money
from joanie.core.templatetags.extra_tags import (
base64_static,
+ format_currency_with_symbol,
iso8601_to_date,
iso8601_to_duration,
join_and,
@@ -165,3 +168,25 @@ def test_templatetags_extra_tags_iso8601_to_date(self):
result = iso8601_to_date(date_string, "SHORT_DATETIME_FORMAT")
self.assertEqual(result, "02/29/2024 1:37 p.m.")
+
+ def test_templatetags_extra_tags_format_currency_with_symbol(self):
+ """
+ The template tag `format_currency_with_symbol` should return the formatted amount
+ with the currency symbol according to the `settings.DEFAULT_CURRENCY`
+ and the way it should format according with `setting.JOANIE_FORMAT_LOCAL_CURRENCY` value.
+ """
+ amount = Money("200.00")
+ with override_settings(LANGUAGE_CODE="en_us", DEFAULT_CURRENCY="EUR"):
+ formatted_amount_with_currency_1 = format_currency_with_symbol(amount)
+
+ self.assertEqual(formatted_amount_with_currency_1, "€200.00")
+
+ with override_settings(LANGUAGE_CODE="en_us", DEFAULT_CURRENCY="USD"):
+ formatted_amount_with_currency_2 = format_currency_with_symbol(amount)
+
+ self.assertEqual(formatted_amount_with_currency_2, "$200.00")
+
+ with override_settings(LANGUAGE_CODE="fr-fr", DEFAULT_CURRENCY="EUR"):
+ formatted_amount_with_currency_3 = format_currency_with_symbol(amount)
+ # '\xa0' represents a non-breaking space in Unicode.
+ self.assertEqual(formatted_amount_with_currency_3, "200,00\xa0€")
diff --git a/src/backend/joanie/tests/core/test_utils_course_product_relation.py b/src/backend/joanie/tests/core/test_utils_course_product_relation.py
index 7ac685ed8..f4147e13e 100644
--- a/src/backend/joanie/tests/core/test_utils_course_product_relation.py
+++ b/src/backend/joanie/tests/core/test_utils_course_product_relation.py
@@ -50,7 +50,7 @@ def test_utils_course_product_relation_get_orders_made(self):
course=course_product_relation.course,
)
for order in orders:
- order.submit()
+ order.init_flow()
result = get_orders(course_product_relation=course_product_relation)
@@ -96,7 +96,7 @@ def test_utils_course_product_relation_get_generated_certificates(self):
course=course_product_relation.course,
)
for order in orders:
- order.submit()
+ order.init_flow()
factories.OrderCertificateFactory(order=order)
generated_certificates_queryset = get_generated_certificates(
diff --git a/src/backend/joanie/tests/core/test_utils_payment_schedule.py b/src/backend/joanie/tests/core/test_utils_payment_schedule.py
index fe039f62e..406bdacc0 100644
--- a/src/backend/joanie/tests/core/test_utils_payment_schedule.py
+++ b/src/backend/joanie/tests/core/test_utils_payment_schedule.py
@@ -3,21 +3,30 @@
"""
import uuid
-from datetime import date, datetime
+from datetime import date, datetime, timedelta
+from decimal import Decimal as D
from unittest import mock
from zoneinfo import ZoneInfo
from django.conf import settings
+from django.core import mail
from django.test import TestCase
from django.test.utils import override_settings
+from django.utils import timezone
from stockholm import Money
-from joanie.core.enums import PAYMENT_STATE_PENDING
+from joanie.core import factories
+from joanie.core.enums import (
+ ORDER_STATE_PENDING_PAYMENT,
+ PAYMENT_STATE_PAID,
+ PAYMENT_STATE_PENDING,
+)
+from joanie.core.exceptions import InvalidConversionError
from joanie.core.utils import payment_schedule
from joanie.tests.base import BaseLogMixinTestCase
-# pylint: disable=protected-access
+# pylint: disable=protected-access, too-many-public-methods
@override_settings(
@@ -28,6 +37,7 @@
100: (20, 30, 30, 20),
},
DEFAULT_CURRENCY="EUR",
+ JOANIE_INSTALLMENT_REMINDER_PERIOD_DAYS=2,
)
class PaymentScheduleUtilsTestCase(TestCase, BaseLogMixinTestCase):
"""
@@ -181,7 +191,7 @@ def test_utils_payment_schedule_calculate_due_dates_withdrawal_date_close_to_cou
self,
):
"""
- calculate due dates event with withdrawal date corresponding to the signed contract date
+ Calculate due dates event with withdrawal date corresponding to the signed contract date
"""
withdrawal_date = date(2024, 1, 1)
course_start_date = date(2024, 1, 10)
@@ -201,6 +211,30 @@ def test_utils_payment_schedule_calculate_due_dates_withdrawal_date_close_to_cou
],
)
+ def test_utils_payment_schedule_calculate_due_dates_start_date_before_withdrawal(
+ self,
+ ):
+ """
+ Check that the due dates are correctly calculated when the course start date is before
+ the withdrawal date
+ """
+ withdrawal_date = date(2024, 1, 1)
+ course_start_date = date(2023, 2, 1)
+ course_end_date = date(2024, 3, 20)
+ percentages_count = 2
+
+ due_dates = payment_schedule._calculate_due_dates(
+ withdrawal_date, course_start_date, course_end_date, percentages_count
+ )
+
+ self.assertEqual(
+ due_dates,
+ [
+ date(2024, 1, 1),
+ date(2024, 2, 1),
+ ],
+ )
+
def test_utils_payment_schedule_calculate_installments(self):
"""
Check that the installments are correctly calculated
@@ -237,11 +271,12 @@ def test_utils_payment_schedule_calculate_installments(self):
],
)
- def test_utils_payment_schedule_generate_2_parts_withdrawal_date_higher_than_course_date_date(
+ def test_utils_payment_schedule_generate_2_parts_withdrawal_date_higher_than_course_start_date(
self,
):
"""
- Check that order's schedule is correctly set for 1 part
+ Check that order's schedule is correctly set for 2 part when the withdrawal date is
+ higher than the course start date
"""
total = 3
signed_contract_date = datetime(2024, 1, 1, 14, tzinfo=ZoneInfo("UTC"))
@@ -305,7 +340,7 @@ def test_utils_payment_schedule_generate_1_part(self):
def test_utils_payment_schedule_generate_2_parts(self):
"""
- Check that order's schedule is correctly set for 1 part
+ Check that order's schedule is correctly set for 2 part
"""
total = 3
signed_contract_date = datetime(2024, 1, 1, 14, tzinfo=ZoneInfo("UTC"))
@@ -569,3 +604,353 @@ def test_utils_payment_schedule_generate_4_parts_tricky_amount(self):
},
],
)
+
+ def test_utils_is_installment_to_debit_today(self):
+ """
+ Check that the installment is to debit if the due date is today.
+ """
+ installment = {
+ "state": PAYMENT_STATE_PENDING,
+ "due_date": date(2024, 1, 17),
+ }
+
+ mocked_now = date(2024, 1, 17)
+ with mock.patch("django.utils.timezone.localdate", return_value=mocked_now):
+ self.assertEqual(
+ payment_schedule.is_installment_to_debit(installment), True
+ )
+
+ def test_utils_is_installment_to_debit_past(self):
+ """
+ Check that the installment is to debit if the due date is reached.
+ """
+ installment = {
+ "state": PAYMENT_STATE_PENDING,
+ "due_date": date(2024, 1, 13),
+ }
+
+ mocked_now = date(2024, 1, 17)
+ with mock.patch("django.utils.timezone.localdate", return_value=mocked_now):
+ self.assertEqual(
+ payment_schedule.is_installment_to_debit(installment), True
+ )
+
+ def test_utils_is_installment_to_debit_paid_today(self):
+ """
+ Check that the installment is not to debit if the due date is today but its
+ state is paid
+ """
+ installment = {
+ "state": PAYMENT_STATE_PAID,
+ "due_date": date(2024, 1, 17),
+ }
+
+ mocked_now = date(2024, 1, 17)
+ with mock.patch("django.utils.timezone.localdate", return_value=mocked_now):
+ self.assertEqual(
+ payment_schedule.is_installment_to_debit(installment), False
+ )
+
+ def test_utils_has_installments_to_debit_true(self):
+ """
+ Check that the order has installments to debit if at least one is to debit.
+ """
+ order = factories.OrderFactory(
+ state=ORDER_STATE_PENDING_PAYMENT,
+ payment_schedule=[
+ {
+ "id": "d9356dd7-19a6-4695-b18e-ad93af41424a",
+ "amount": "200.00",
+ "due_date": "2023-01-17",
+ "state": PAYMENT_STATE_PAID,
+ },
+ {
+ "id": "1932fbc5-d971-48aa-8fee-6d637c3154a5",
+ "amount": "300.50",
+ "due_date": "2024-01-17",
+ "state": PAYMENT_STATE_PENDING,
+ },
+ ],
+ )
+ order.refresh_from_db()
+
+ mocked_now = date(2024, 1, 17)
+ with mock.patch("django.utils.timezone.localdate", return_value=mocked_now):
+ self.assertEqual(payment_schedule.has_installments_to_debit(order), True)
+
+ def test_utils_has_installments_to_debit_false(self):
+ """
+ Check that the order has not installments to debit if no installment are pending
+ or due date is not reached.
+ """
+ order = factories.OrderFactory(
+ state=ORDER_STATE_PENDING_PAYMENT,
+ payment_schedule=[
+ {
+ "id": "d9356dd7-19a6-4695-b18e-ad93af41424a",
+ "amount": "200.00",
+ "due_date": "2023-01-17",
+ "state": PAYMENT_STATE_PAID,
+ },
+ {
+ "id": "1932fbc5-d971-48aa-8fee-6d637c3154a5",
+ "amount": "300.50",
+ "due_date": "2024-02-17",
+ "state": PAYMENT_STATE_PENDING,
+ },
+ ],
+ )
+ order.refresh_from_db()
+
+ mocked_now = date(2024, 1, 17)
+ with mock.patch("django.utils.timezone.localdate", return_value=mocked_now):
+ self.assertEqual(payment_schedule.has_installments_to_debit(order), False)
+
+ def test_utils_payment_schedule_convert_date_str_to_date_object(self):
+ """
+ Check that the method `convert_date_str_to_date_object` converts an isoformat string
+ of a date into a date object
+ """
+ date_str = "2024-04-26"
+
+ date_object = payment_schedule.convert_date_str_to_date_object(date_str)
+
+ self.assertEqual(date_object, date(2024, 4, 26))
+
+ def test_utils_payment_schedule_convert_date_str_to_date_object_raises_invalid_conversion(
+ self,
+ ):
+ """
+ Check that the method `convert_date_str_to_date_object` raises the exception
+ `InvalidConversionError` when a string with an incorrect ISO date format is passed as
+ the `due_date` value.
+ """
+ date_str_1 = "abc-04-26"
+ date_str_2 = "2024-08-30T14:41:08.504233"
+
+ with self.assertRaises(InvalidConversionError) as context:
+ payment_schedule.convert_date_str_to_date_object(date_str_1)
+
+ self.assertEqual(
+ str(context.exception),
+ "Invalid date format for date_str: Invalid isoformat string: 'abc-04-26'.",
+ )
+
+ with self.assertRaises(InvalidConversionError) as context:
+ payment_schedule.convert_date_str_to_date_object(date_str_2)
+
+ self.assertEqual(
+ str(context.exception),
+ "Invalid date format for date_str: Invalid isoformat "
+ "string: '2024-08-30T14:41:08.504233'.",
+ )
+
+ def test_utils_payment_schedule_convert_amount_str_to_money_object(self):
+ """
+ Check that the method `convert_amount_str_to_money_object` converts a string value
+ representing an amount into a money object.
+ """
+ item = "22.00"
+
+ amount = payment_schedule.convert_amount_str_to_money_object(item)
+
+ self.assertEqual(amount, Money("22.00"))
+
+ def test_utils_payment_schedule_convert_amount_str_to_money_object_raises_invalid_conversion(
+ self,
+ ):
+ """
+ Check that the method `convert_amount_str_to_money_object` raises the exception
+ `InvalidConversionError` when a string with an incorrect format for an amount is passed.
+ """
+ amount_1 = "abc"
+ amount_2 = "124,00"
+
+ with self.assertRaises(InvalidConversionError) as context:
+ payment_schedule.convert_amount_str_to_money_object(amount_1)
+
+ self.assertEqual(
+ str(context.exception),
+ "Invalid format for amount: Input value cannot be used as monetary amount : 'abc'.",
+ )
+
+ with self.assertRaises(InvalidConversionError) as context:
+ payment_schedule.convert_amount_str_to_money_object(amount_2)
+
+ self.assertEqual(
+ str(context.exception),
+ "Invalid format for amount: Input value cannot be used as monetary amount : '124,00'.",
+ )
+
+ def test_utils_is_next_installment_to_debit_in_payment_schedule(self):
+ """
+ The method `is_next_installment_to_debit` should return a boolean
+ whether the installment due date is equal to the passed parameter `due_date`
+ value set in the settings named `JOANIE_INSTALLMENT_REMINDER_PERIOD_DAYS`.
+ """
+ order = factories.OrderGeneratorFactory(
+ state=ORDER_STATE_PENDING_PAYMENT,
+ product__price=D("100"),
+ )
+ order.payment_schedule[0]["state"] = PAYMENT_STATE_PAID
+ order.payment_schedule[0]["due_date"] = date(2024, 1, 17)
+ order.payment_schedule[1]["due_date"] = date(2024, 2, 17)
+ order.payment_schedule[2]["due_date"] = date(2024, 3, 17)
+ order.payment_schedule[3]["due_date"] = date(2024, 4, 17)
+ order.save()
+
+ with mock.patch(
+ "django.utils.timezone.localdate", return_value=date(2024, 2, 15)
+ ):
+ due_date = timezone.localdate() + timedelta(
+ days=settings.JOANIE_INSTALLMENT_REMINDER_PERIOD_DAYS
+ )
+ # Should return False for the 1st installment
+ self.assertEqual(
+ payment_schedule.is_next_installment_to_debit(
+ installment=order.payment_schedule[0],
+ due_date=due_date,
+ ),
+ False,
+ )
+ # Should return True for the 2nd installment
+ self.assertEqual(
+ payment_schedule.is_next_installment_to_debit(
+ installment=order.payment_schedule[1],
+ due_date=due_date,
+ ),
+ True,
+ )
+ # Should return False for the 3rd installment
+ self.assertEqual(
+ payment_schedule.is_next_installment_to_debit(
+ installment=order.payment_schedule[2],
+ due_date=due_date,
+ ),
+ False,
+ )
+ # Should return False for the 4th installment
+ self.assertEqual(
+ payment_schedule.is_next_installment_to_debit(
+ installment=order.payment_schedule[3],
+ due_date=due_date,
+ ),
+ False,
+ )
+
+ def test_utils_send_mail_reminder_for_installment_debit(self):
+ """
+ The method `send_mail_reminder_for_installment_debit` should send an email with
+ the informations about the upcoming installment. We should find the number of days
+ until the debit according to the setting `JOANIE_INSTALLMENT_REMINDER_PERIOD_DAYS`,
+ which is 2 days for this test.
+ """
+ order = factories.OrderGeneratorFactory(
+ owner=factories.UserFactory(
+ first_name="John",
+ last_name="Doe",
+ email="sam@fun-test.fr",
+ language="en-us",
+ ),
+ state=ORDER_STATE_PENDING_PAYMENT,
+ product__price=D("100"),
+ product__title="Product 1",
+ )
+ order.payment_schedule[0]["due_date"] = date(2024, 1, 17)
+ order.payment_schedule[0]["state"] = PAYMENT_STATE_PAID
+ order.payment_schedule[1]["due_date"] = date(2024, 2, 17)
+ order.payment_schedule[1]["due_date"] = PAYMENT_STATE_PENDING
+ order.save()
+
+ payment_schedule.send_mail_reminder_for_installment_debit(
+ order, order.payment_schedule[1]
+ )
+
+ self.assertEqual(mail.outbox[0].to[0], "sam@fun-test.fr")
+ self.assertIn("will be debited in", mail.outbox[0].subject)
+
+ # Check body
+ email_content = " ".join(mail.outbox[0].body.split())
+ fullname = order.owner.get_full_name()
+ self.assertIn(f"Hello {fullname}", email_content)
+ self.assertIn("installment will be withdrawn on 2 days", email_content)
+ self.assertIn("We will try to debit an amount of", email_content)
+ self.assertIn("30.00", email_content)
+ self.assertIn("Product 1", email_content)
+
+ def test_utils_send_mail_reminder_for_installment_debit_in_french_language(self):
+ """
+ The method `send_mail_reminder_for_installment_debit` should send an email with
+ the informations about the upcoming installment in the current language of the user
+ when the translation exists in french language.
+ """
+ owner = factories.UserFactory(
+ first_name="John",
+ last_name="Doe",
+ email="sam@fun-test.fr",
+ language="fr-fr",
+ )
+ product = factories.ProductFactory(price=D("100.00"), title="Product 1")
+ product.translations.create(language_code="fr-fr", title="Produit 1")
+ order = factories.OrderGeneratorFactory(
+ product=product,
+ owner=owner,
+ state=ORDER_STATE_PENDING_PAYMENT,
+ )
+ order.payment_schedule[0]["due_date"] = date(2024, 1, 17)
+ order.payment_schedule[0]["state"] = PAYMENT_STATE_PAID
+ order.payment_schedule[1]["due_date"] = date(2024, 2, 17)
+ order.payment_schedule[1]["due_date"] = PAYMENT_STATE_PENDING
+ order.save()
+
+ payment_schedule.send_mail_reminder_for_installment_debit(
+ order, order.payment_schedule[1]
+ )
+
+ self.assertEqual(mail.outbox[0].to[0], "sam@fun-test.fr")
+ email_content = " ".join(mail.outbox[0].body.split())
+ self.assertIn("Produit 1", email_content)
+ self.assertIn("30,00", email_content)
+
+ @override_settings(
+ LANGUAGES=(
+ ("en-us", ("English")),
+ ("fr-fr", ("French")),
+ ("de-de", ("German")),
+ )
+ )
+ def test_utils_send_mail_reminder_for_installment_debit_should_use_fallback_language(
+ self,
+ ):
+ """
+ The method `send_mail_reminder_for_installment_debit` should send an email with
+ the informations about the upcoming installment in the fallback language if the
+ translation does not exist in the current language of the user.
+ """
+ owner = factories.UserFactory(
+ first_name="John",
+ last_name="Doe",
+ email="sam@fun-test.de",
+ language="de-de",
+ )
+ product = factories.ProductFactory(price=D("100.00"), title="Product 1")
+ product.translations.create(language_code="fr-fr", title="Produit 1")
+ order = factories.OrderGeneratorFactory(
+ product=product,
+ owner=owner,
+ state=ORDER_STATE_PENDING_PAYMENT,
+ )
+ order.payment_schedule[0]["due_date"] = date(2024, 1, 17)
+ order.payment_schedule[0]["state"] = PAYMENT_STATE_PAID
+ order.payment_schedule[1]["due_date"] = date(2024, 2, 17)
+ order.payment_schedule[1]["due_date"] = PAYMENT_STATE_PENDING
+ order.save()
+
+ payment_schedule.send_mail_reminder_for_installment_debit(
+ order, order.payment_schedule[1]
+ )
+
+ self.assertEqual(mail.outbox[0].to[0], "sam@fun-test.de")
+ email_content = " ".join(mail.outbox[0].body.split())
+ self.assertIn("Product 1", email_content)
+ self.assertIn("30.00", email_content)
diff --git a/src/backend/joanie/tests/core/utils/test_contract.py b/src/backend/joanie/tests/core/utils/test_contract.py
index e90200827..d0ad551b1 100644
--- a/src/backend/joanie/tests/core/utils/test_contract.py
+++ b/src/backend/joanie/tests/core/utils/test_contract.py
@@ -23,6 +23,51 @@
class UtilsContractTestCase(TestCase):
"""Test suite to generate a ZIP archive of signed contract PDF files in bytes utility"""
+ def test_utils_contract_get_signature_backend_references_states(
+ self,
+ ):
+ """
+ From a Course Product Relation product object, we should be able to find the
+ contract's signature backend references that are attached to the validated
+ orders only for a specific course and product. It should return an iterator with
+ signature backend references.
+ All orders but the canceled ones should be returned.
+ """
+ for state, _ in enums.ORDER_STATE_CHOICES:
+ with self.subTest(state=state):
+ relation = factories.CourseProductRelationFactory(
+ product__contract_definition=factories.ContractDefinitionFactory()
+ )
+ contract = factories.ContractFactory(
+ # order__owner=users[index],
+ order__product=relation.product,
+ order__course=relation.course,
+ order__state=state,
+ definition_checksum="1234",
+ context={"foo": "bar"},
+ student_signed_on=timezone.now(),
+ organization_signed_on=timezone.now(),
+ )
+
+ signature_backend_references_generator = (
+ contract_utility.get_signature_backend_references(
+ course_product_relation=relation, organization=None
+ )
+ )
+ signature_backend_references_list = list(
+ signature_backend_references_generator
+ )
+
+ if state == enums.ORDER_STATE_CANCELED:
+ self.assertEqual(len(signature_backend_references_list), 0)
+ self.assertEqual(signature_backend_references_list, [])
+ else:
+ self.assertEqual(len(signature_backend_references_list), 1)
+ self.assertEqual(
+ signature_backend_references_list,
+ [contract.signature_backend_reference],
+ )
+
def test_utils_contract_get_signature_backend_references_with_no_signed_contracts_yet(
self,
):
@@ -51,8 +96,7 @@ def test_utils_contract_get_signature_backend_references_with_no_signed_contract
enums.ORDER_STATE_CANCELED,
enums.ORDER_STATE_DRAFT,
enums.ORDER_STATE_PENDING,
- enums.ORDER_STATE_SUBMITTED,
- enums.ORDER_STATE_VALIDATED,
+ enums.ORDER_STATE_COMPLETED,
]
),
signature_backend_reference=signature_reference,
@@ -94,7 +138,7 @@ def test_utils_contract_get_signature_backend_references_with_many_signed_contra
order__owner=users[index],
order__product=relation.product,
order__course=relation.course,
- order__state=enums.ORDER_STATE_VALIDATED,
+ order__state=enums.ORDER_STATE_COMPLETED,
signature_backend_reference=signature_reference,
definition_checksum="1234",
context={"foo": "bar"},
@@ -142,8 +186,7 @@ def test_utils_contract_get_signature_backend_references_no_signed_contracts_fro
enums.ORDER_STATE_CANCELED,
enums.ORDER_STATE_DRAFT,
enums.ORDER_STATE_PENDING,
- enums.ORDER_STATE_SUBMITTED,
- enums.ORDER_STATE_VALIDATED,
+ enums.ORDER_STATE_COMPLETED,
]
),
signature_backend_reference=signature_reference,
@@ -185,7 +228,7 @@ def test_utils_contract_get_signature_backend_references_signed_contracts_from_o
order__owner=users[index],
order__product=relation.product,
order__course=relation.course,
- order__state=enums.ORDER_STATE_VALIDATED,
+ order__state=enums.ORDER_STATE_COMPLETED,
signature_backend_reference=signature_reference,
definition_checksum="1234",
context={"foo": "bar"},
@@ -241,8 +284,7 @@ def test_utils_contract_get_signature_backend_references_no_signed_contracts_fro
enums.ORDER_STATE_CANCELED,
enums.ORDER_STATE_DRAFT,
enums.ORDER_STATE_PENDING,
- enums.ORDER_STATE_SUBMITTED,
- enums.ORDER_STATE_VALIDATED,
+ enums.ORDER_STATE_COMPLETED,
]
),
signature_backend_reference=signature_reference,
@@ -291,7 +333,7 @@ def test_utils_contract_get_signature_backend_references_signed_contracts_from_e
order__product=relation.product,
order__course=None,
order__enrollment=enrollment,
- order__state=enums.ORDER_STATE_VALIDATED,
+ order__state=enums.ORDER_STATE_COMPLETED,
signature_backend_reference=signature_reference,
definition_checksum="1234",
context={"foo": "bar"},
@@ -349,7 +391,7 @@ def test_utils_contract_get_signature_backend_reference_extra_filters_org_access
order__product=relation.product,
order__course=None,
order__enrollment=enrollment,
- order__state=enums.ORDER_STATE_VALIDATED,
+ order__state=enums.ORDER_STATE_COMPLETED,
signature_backend_reference=signature_reference,
definition_checksum="1234",
context={"foo": "bar"},
@@ -409,7 +451,7 @@ def test_utils_contract_get_signature_backend_reference_extra_filters_without_us
order__product=relation.product,
order__course=None,
order__enrollment=enrollment,
- order__state=enums.ORDER_STATE_VALIDATED,
+ order__state=enums.ORDER_STATE_COMPLETED,
signature_backend_reference=signature_reference,
definition_checksum="1234",
context={"foo": "bar"},
@@ -541,7 +583,7 @@ def test_utils_contract_generate_zip_archive_success(self):
owner=users[index],
product=relation.product,
course=relation.course,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
main_invoice=InvoiceFactory(
recipient_address__address="1 Rue de L'Exemple",
recipient_address__postcode=75000,
@@ -623,7 +665,7 @@ def test_utils_contract_get_signature_backend_references_with_course_product_rel
order__owner=learners[index],
order__product=relation.product,
order__course=relation.course,
- order__state=enums.ORDER_STATE_VALIDATED,
+ order__state=enums.ORDER_STATE_COMPLETED,
signature_backend_reference=signature_reference,
definition_checksum="1234",
context={"foo": "bar"},
@@ -656,7 +698,7 @@ def test_utils_contract_organization_has_owner_without_owners_returns_false(
order = factories.OrderFactory(
owner=user,
product__contract_definition=factories.ContractDefinitionFactory(),
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
factories.ContractFactory(
order=order, definition=order.product.contract_definition
@@ -675,7 +717,7 @@ def test_utils_contract_organization_has_owner_returns_true(
order = factories.OrderFactory(
owner=user,
product__contract_definition=factories.ContractDefinitionFactory(),
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
factories.ContractFactory(
order=order, definition=order.product.contract_definition
@@ -692,55 +734,67 @@ def test_utils_contract_get_signature_references_student_has_signed(self):
"""
Should return the signature backend references of orders that are owned
by an organization and where it still awaits the organization's signature.
- """
- user = factories.UserFactory()
- order = factories.OrderFactory(
- owner=user,
- product__contract_definition=factories.ContractDefinitionFactory(),
- state=enums.ORDER_STATE_VALIDATED,
- )
- factories.ContractFactory(
- order=order,
- definition=order.product.contract_definition,
- signature_backend_reference="wfl_fake_dummy_id",
- definition_checksum="1234",
- context="context",
- submitted_for_signature_on=timezone.now(),
- student_signed_on=timezone.now(),
- organization_signed_on=None,
- )
- order_found = contract_utility.get_signature_references(
- organization_id=order.organization.id, student_has_not_signed=False
- )
-
- self.assertEqual(list(order_found), ["wfl_fake_dummy_id"])
+ Contracts with a cancelled order should not be returned.
+ """
+ for state, _ in enums.ORDER_STATE_CHOICES:
+ with self.subTest(state=state):
+ user = factories.UserFactory()
+ order = factories.OrderFactory(
+ owner=user,
+ product__contract_definition=factories.ContractDefinitionFactory(),
+ state=state,
+ )
+ factories.ContractFactory(
+ order=order,
+ definition=order.product.contract_definition,
+ signature_backend_reference="wfl_fake_dummy_id",
+ definition_checksum="1234",
+ context="context",
+ submitted_for_signature_on=timezone.now(),
+ student_signed_on=timezone.now(),
+ organization_signed_on=None,
+ )
+ order_found = contract_utility.get_signature_references(
+ organization_id=order.organization.id, student_has_not_signed=False
+ )
+
+ if state == enums.ORDER_STATE_CANCELED:
+ self.assertEqual(list(order_found), [])
+ else:
+ self.assertEqual(list(order_found), ["wfl_fake_dummy_id"])
def test_utils_contract_get_signature_references_student_has_not_signed(self):
"""
Should return the signature backend references that are owned by an organization
and where there is no signature yet but has been submitted for signature.
- """
- user = factories.UserFactory()
- order = factories.OrderFactory(
- owner=user,
- product__contract_definition=factories.ContractDefinitionFactory(),
- state=enums.ORDER_STATE_VALIDATED,
- )
- factories.ContractFactory(
- order=order,
- definition=order.product.contract_definition,
- signature_backend_reference="wfl_fake_dummy_id",
- definition_checksum="1234",
- context="context",
- submitted_for_signature_on=timezone.now(),
- student_signed_on=None,
- organization_signed_on=None,
- )
- order_found = contract_utility.get_signature_references(
- organization_id=order.organization.id, student_has_not_signed=True
- )
-
- self.assertEqual(list(order_found), ["wfl_fake_dummy_id"])
+ Contracts with a cancelled order should not be returned.
+ """
+ for state, _ in enums.ORDER_STATE_CHOICES:
+ with self.subTest(state=state):
+ user = factories.UserFactory()
+ order = factories.OrderFactory(
+ owner=user,
+ product__contract_definition=factories.ContractDefinitionFactory(),
+ state=state,
+ )
+ factories.ContractFactory(
+ order=order,
+ definition=order.product.contract_definition,
+ signature_backend_reference="wfl_fake_dummy_id",
+ definition_checksum="1234",
+ context="context",
+ submitted_for_signature_on=timezone.now(),
+ student_signed_on=None,
+ organization_signed_on=None,
+ )
+ order_found = contract_utility.get_signature_references(
+ organization_id=order.organization.id, student_has_not_signed=True
+ )
+
+ if state == enums.ORDER_STATE_CANCELED:
+ self.assertEqual(list(order_found), [])
+ else:
+ self.assertEqual(list(order_found), ["wfl_fake_dummy_id"])
def test_utils_contract_get_signature_references_should_not_find_order(self):
"""
@@ -751,7 +805,7 @@ def test_utils_contract_get_signature_references_should_not_find_order(self):
order = factories.OrderFactory(
owner=user,
product__contract_definition=factories.ContractDefinitionFactory(),
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
)
contract = factories.ContractFactory(
order=order,
@@ -799,7 +853,7 @@ def test_utils_contract_update_signatories_for_contracts_but_no_awaiting_contrac
order = factories.OrderFactory(
product=relation.product,
course=relation.course,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
organization=organization,
)
factories.ContractFactory(
@@ -820,7 +874,7 @@ def test_utils_contract_update_signatories_for_contracts_but_no_awaiting_contrac
self.assertEqual(
models.Contract.objects.filter(
submitted_for_signature_on__isnull=False,
- order__state=enums.ORDER_STATE_VALIDATED,
+ order__state=enums.ORDER_STATE_COMPLETED,
order__organization_id=organization.id,
organization_signed_on__isnull=False,
student_signed_on__isnull=False,
@@ -845,7 +899,7 @@ def test_utils_contract_update_signatories_for_contracts(self):
owner=learners[index],
product=relation.product,
course=relation.course,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
organization=organization,
)
factories.ContractFactory(
@@ -861,7 +915,7 @@ def test_utils_contract_update_signatories_for_contracts(self):
owner=learners[2],
product=relation.product,
course=relation.course,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
organization=organization,
)
# This contract will need a full update for student and organization
@@ -901,7 +955,7 @@ def test_utils_contract_update_signatories_for_contracts(self):
self.assertEqual(
models.Contract.objects.filter(
submitted_for_signature_on__isnull=False,
- order__state=enums.ORDER_STATE_VALIDATED,
+ order__state=enums.ORDER_STATE_COMPLETED,
order__organization_id=organization.id,
organization_signed_on__isnull=True,
student_signed_on__isnull=True,
@@ -911,7 +965,7 @@ def test_utils_contract_update_signatories_for_contracts(self):
self.assertEqual(
models.Contract.objects.filter(
submitted_for_signature_on__isnull=False,
- order__state=enums.ORDER_STATE_VALIDATED,
+ order__state=enums.ORDER_STATE_COMPLETED,
order__organization_id=organization.id,
organization_signed_on__isnull=True,
student_signed_on__isnull=False,
diff --git a/src/backend/joanie/tests/core/utils/test_contract_definition_generate_document_context.py b/src/backend/joanie/tests/core/utils/test_contract_definition_generate_document_context.py
index df8697d73..92eaa2196 100644
--- a/src/backend/joanie/tests/core/utils/test_contract_definition_generate_document_context.py
+++ b/src/backend/joanie/tests/core/utils/test_contract_definition_generate_document_context.py
@@ -2,16 +2,22 @@
from datetime import datetime, timedelta, timezone
from decimal import Decimal
+from io import BytesIO
from unittest import mock
-from django.contrib.sites.models import Site
from django.test import TestCase, override_settings
from django.utils import timezone as django_timezone
+from pdfminer.high_level import extract_text as pdf_extract_text
+
from joanie.core import enums, factories
-from joanie.core.utils import contract_definition, image_to_base64
+from joanie.core.models import DocumentImage
+from joanie.core.utils import contract_definition, image_to_base64, issuers
+from joanie.core.utils.contract_definition import ORGANIZATION_FALLBACK_LOGO
from joanie.payment.factories import InvoiceFactory
+PROCESSOR_PATH = "joanie.tests.core.utils.test_contract_definition_generate_document_context._processor_for_test_suite" # pylint: disable=line-too-long
+
def _processor_for_test_suite(context):
"""A processor for the test of the document context generation."""
@@ -44,10 +50,6 @@ def test_utils_contract_definition_generate_document_context_with_order(self):
last_name="",
phone_number="0123456789",
)
- factories.SiteConfigFactory(
- site=Site.objects.get_current(),
- terms_and_conditions="## Terms and conditions",
- )
user_address = factories.UserAddressFactory(
owner=user,
first_name="John",
@@ -109,16 +111,24 @@ def test_utils_contract_definition_generate_document_context_with_order(self):
owner=user,
product=relation.product,
course=relation.course,
- state=enums.ORDER_STATE_VALIDATED,
+ state=enums.ORDER_STATE_COMPLETED,
main_invoice=InvoiceFactory(recipient_address=user_address),
)
factories.OrderTargetCourseRelationFactory(
course=relation.course, order=order, position=1
)
+
+ context = contract_definition.generate_document_context(
+ contract_definition=order.product.contract_definition,
+ user=user,
+ order=order,
+ )
+
+ organization_logo = DocumentImage.objects.get()
+
expected_context = {
"contract": {
"body": "
+
+ )}
+
+
diff --git a/src/frontend/admin/src/components/templates/orders/view/translations.tsx b/src/frontend/admin/src/components/templates/orders/view/translations.tsx
index 8cf3ad64f..08710c27d 100644
--- a/src/frontend/admin/src/components/templates/orders/view/translations.tsx
+++ b/src/frontend/admin/src/components/templates/orders/view/translations.tsx
@@ -87,18 +87,6 @@ export const orderViewMessages = defineMessages({
defaultMessage: "tax included",
description: "Helper text for the price filed",
},
- hasConsentToTerms: {
- id: "components.templates.orders.view.hasConsentToTerms",
- defaultMessage:
- "The user has accepted the terms and conditions when purchasing",
- description: "Text for the has consent to term label",
- },
- hasNotConsentToTerms: {
- id: "components.templates.orders.view.hasNotConsentToTerms",
- defaultMessage:
- "The user has not accepted the terms and conditions when purchasing",
- description: "Text for the has consent to term label",
- },
certificate: {
id: "components.templates.orders.view.certificate",
defaultMessage: "Certificate",
@@ -186,6 +174,16 @@ export const orderViewMessages = defineMessages({
defaultMessage: "List of sub-invoices",
description: "Sub invoice list title",
},
+ paymentSchedule: {
+ id: "components.templates.orders.view.paymentSchedule",
+ defaultMessage: "Payment schedule",
+ description: "Payment schedule title",
+ },
+ noPaymentMethod: {
+ id: "components.templates.orders.view.noPaymentMethod",
+ defaultMessage: "No payment method has been defined.",
+ description: "Message displayed when order has no payment method",
+ },
});
export const invoiceTypesMessages = defineMessages({
@@ -225,10 +223,25 @@ export const orderStatesMessages = defineMessages({
defaultMessage: "Draft",
description: "Text for draft order state",
},
- submitted: {
- id: "components.templates.orders.view.orderStatesMessages.submitted",
- defaultMessage: "Submitted",
- description: "Text for submitted order state",
+ assigned: {
+ id: "components.templates.orders.view.orderStatesMessages.assigned",
+ defaultMessage: "Assigned",
+ description: "Text for assigned order state",
+ },
+ to_save_payment_method: {
+ id: "components.templates.orders.view.orderStatesMessages.to_save_payment_method",
+ defaultMessage: "To save payment method",
+ description: "Text for to save payment method order state",
+ },
+ to_sign: {
+ id: "components.templates.orders.view.orderStatesMessages.to_sign",
+ defaultMessage: "To sign",
+ description: "Text for to sign order state",
+ },
+ signing: {
+ id: "components.templates.orders.view.orderStatesMessages.signing",
+ defaultMessage: "Signing",
+ description: "Text for signing order state",
},
pending: {
id: "components.templates.orders.view.orderStatesMessages.pending",
@@ -240,9 +253,24 @@ export const orderStatesMessages = defineMessages({
defaultMessage: "Canceled",
description: "Text for canceled order state",
},
- validated: {
- id: "components.templates.orders.view.orderStatesMessages.validated",
- defaultMessage: "Validated",
- description: "Text for validated order state",
+ pending_payment: {
+ id: "components.templates.orders.view.orderStatesMessages.pending_payment",
+ defaultMessage: "Pending payment",
+ description: "Text for pending payment order state",
+ },
+ failed_payment: {
+ id: "components.templates.orders.view.orderStatesMessages.failed_payment",
+ defaultMessage: "Failed payment",
+ description: "Text for failed payment order state",
+ },
+ no_payment: {
+ id: "components.templates.orders.view.orderStatesMessages.no_payment",
+ defaultMessage: "No payment",
+ description: "Text for no payment order state",
+ },
+ completed: {
+ id: "components.templates.orders.view.orderStatesMessages.completed",
+ defaultMessage: "Completed",
+ description: "Text for completed order state",
},
});
diff --git a/src/frontend/admin/src/services/api/models/Order.ts b/src/frontend/admin/src/services/api/models/Order.ts
index 2627a7a6a..ea79c9650 100644
--- a/src/frontend/admin/src/services/api/models/Order.ts
+++ b/src/frontend/admin/src/services/api/models/Order.ts
@@ -24,6 +24,28 @@ export type OrderListItem = AbstractOrder & {
product_title: string;
};
+export enum PaymentStatesEnum {
+ PAYMENT_STATE_PENDING = "pending",
+ PAYMENT_STATE_PAID = "paid",
+ PAYMENT_STATE_REFUSED = "refused",
+}
+
+export type OrderPaymentSchedule = {
+ id: string;
+ amount: number;
+ currency: string;
+ due_date: string;
+ state: PaymentStatesEnum;
+};
+
+export type OrderCreditCard = {
+ id: string;
+ brand: string;
+ last_numbers: string;
+ expiration_month: number;
+ expiration_year: number;
+};
+
export type Order = AbstractOrder & {
owner: User;
product: ProductSimple;
@@ -33,8 +55,9 @@ export type Order = AbstractOrder & {
enrollment: Nullable;
certificate: Nullable;
main_invoice: OrderMainInvoice;
- has_consent_to_terms: boolean;
contract: Nullable;
+ payment_schedule: Nullable;
+ credit_card: Nullable;
};
export type OrderContractDetails = {
@@ -89,10 +112,16 @@ export enum OrderInvoiceStatusEnum {
export enum OrderStatesEnum {
ORDER_STATE_DRAFT = "draft", // order has been created
- ORDER_STATE_SUBMITTED = "submitted", // order information have been validated
+ ORDER_STATE_ASSIGNED = "assigned", // order has been assigned to an organization
+ ORDER_STATE_TO_SAVE_PAYMENT_METHOD = "to_save_payment_method", // order needs a payment method
+ ORDER_STATE_TO_SIGN = "to_sign", // order needs a contract signature
+ ORDER_STATE_SIGNING = "signing", // order is pending for contract signature validation
ORDER_STATE_PENDING = "pending", // payment has failed but can be retried
ORDER_STATE_CANCELED = "canceled", // has been canceled
- ORDER_STATE_VALIDATED = "validated", // is free or has an invoice linked
+ ORDER_STATE_PENDING_PAYMENT = "pending_payment", // payment is pending
+ 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
}
export const transformOrderToOrderListItem = (order: Order): OrderListItem => {
diff --git a/src/frontend/admin/src/services/factories/credit-cards/index.ts b/src/frontend/admin/src/services/factories/credit-cards/index.ts
new file mode 100644
index 000000000..12b7fad4e
--- /dev/null
+++ b/src/frontend/admin/src/services/factories/credit-cards/index.ts
@@ -0,0 +1,31 @@
+import { faker } from "@faker-js/faker";
+import { OrderCreditCard } from "@/services/api/models/Order";
+
+const build = (override: Partial = {}): OrderCreditCard => ({
+ id: faker.string.uuid(),
+ last_numbers: faker.finance.creditCardNumber().slice(-4),
+ brand: faker.helpers.arrayElement(["Visa", "Mastercard", "Maestro", "Amex"]),
+ expiration_month: faker.date.future().getMonth() + 1,
+ expiration_year: faker.date.future().getFullYear(),
+ ...override,
+});
+
+export function CreditCardFactory(
+ override?: Partial,
+ args_1?: undefined,
+): OrderCreditCard;
+export function CreditCardFactory(
+ count: number,
+ override?: Partial,
+): OrderCreditCard[];
+export function CreditCardFactory(
+ ...args: [
+ number | Partial | undefined,
+ Partial | undefined,
+ ]
+) {
+ if (typeof args[0] === "number") {
+ return [...Array(args[0])].map(() => build(args[1]));
+ }
+ return build(args[0]);
+}
diff --git a/src/frontend/admin/src/services/factories/orders/index.ts b/src/frontend/admin/src/services/factories/orders/index.ts
index 804fed109..94240cd19 100644
--- a/src/frontend/admin/src/services/factories/orders/index.ts
+++ b/src/frontend/admin/src/services/factories/orders/index.ts
@@ -4,7 +4,9 @@ import {
OrderInvoiceStatusEnum,
OrderInvoiceTypesEnum,
OrderListItem,
+ OrderPaymentSchedule,
OrderStatesEnum,
+ PaymentStatesEnum,
} from "@/services/api/models/Order";
import {
ProductFactoryLight,
@@ -14,13 +16,28 @@ import { OrganizationFactory } from "@/services/factories/organizations";
import { OrderGroupFactory } from "@/services/factories/order-group";
import { CourseFactory } from "@/services/factories/courses";
import { UsersFactory } from "@/services/factories/users";
+import { CreditCardFactory } from "@/services/factories/credit-cards";
-const build = (): Order => {
- const totalOrder = faker.number.float({ min: 1, max: 9999 });
+const orderPayment = (
+ due_date: string,
+ amount: number,
+): OrderPaymentSchedule => {
return {
+ id: faker.string.uuid(),
+ amount,
+ currency: "EUR",
+ due_date,
+ state: PaymentStatesEnum.PAYMENT_STATE_PENDING,
+ };
+};
+
+const build = (state?: OrderStatesEnum): Order => {
+ const totalOrder = faker.number.float({ min: 1, max: 9999 });
+ state = state || faker.helpers.arrayElement(Object.values(OrderStatesEnum));
+ const order: Order = {
id: faker.string.uuid(),
created_on: faker.date.anytime().toString(),
- state: faker.helpers.arrayElement(Object.values(OrderStatesEnum)),
+ state,
owner: UsersFactory(),
product: ProductSimpleFactory(),
organization: OrganizationFactory(),
@@ -34,7 +51,6 @@ const build = (): Order => {
definition_title: "Fake definition",
issued_on: faker.date.anytime().toString(),
},
- has_consent_to_terms: faker.datatype.boolean(),
contract: {
definition_title: "Fake contract definition",
id: faker.string.uuid(),
@@ -56,14 +72,48 @@ const build = (): Order => {
type: faker.helpers.arrayElement(Object.values(OrderInvoiceTypesEnum)),
children: [],
},
+ payment_schedule: [
+ orderPayment("6/27/2024", totalOrder / 3),
+ orderPayment("7/27/2024", totalOrder / 3),
+ orderPayment("8/27/2024", totalOrder / 3),
+ ],
+ credit_card: CreditCardFactory(),
};
+ if (
+ ![
+ OrderStatesEnum.ORDER_STATE_PENDING,
+ OrderStatesEnum.ORDER_STATE_NO_PAYMENT,
+ OrderStatesEnum.ORDER_STATE_PENDING_PAYMENT,
+ OrderStatesEnum.ORDER_STATE_FAILED_PAYMENT,
+ OrderStatesEnum.ORDER_STATE_COMPLETED,
+ OrderStatesEnum.ORDER_STATE_CANCELED,
+ ].includes(state)
+ ) {
+ order.credit_card = null;
+ }
+ if (state === OrderStatesEnum.ORDER_STATE_COMPLETED)
+ order.payment_schedule!.forEach((installment) => {
+ installment.state = PaymentStatesEnum.PAYMENT_STATE_PAID;
+ });
+ if (state === OrderStatesEnum.ORDER_STATE_PENDING_PAYMENT)
+ order.payment_schedule![0].state = PaymentStatesEnum.PAYMENT_STATE_PAID;
+ if (state === OrderStatesEnum.ORDER_STATE_NO_PAYMENT)
+ order.payment_schedule![0].state = PaymentStatesEnum.PAYMENT_STATE_REFUSED;
+ if (state === OrderStatesEnum.ORDER_STATE_FAILED_PAYMENT) {
+ order.payment_schedule![0].state = PaymentStatesEnum.PAYMENT_STATE_PAID;
+ order.payment_schedule![1].state = PaymentStatesEnum.PAYMENT_STATE_REFUSED;
+ }
+ return order;
};
export function OrderFactory(): Order;
-export function OrderFactory(count: number): Order[];
-export function OrderFactory(count?: number): Order | Order[] {
+export function OrderFactory(count: number, state?: OrderStatesEnum): Order[];
+export function OrderFactory(
+ count?: number,
+ state?: OrderStatesEnum,
+): Order | Order[] {
if (count) return [...Array(count)].map(build);
- return build();
+ return build(state);
}
const buildOrderListItem = (): OrderListItem => {
diff --git a/src/frontend/admin/src/tests/orders/orders-filters.test.e2e.ts b/src/frontend/admin/src/tests/orders/orders-filters.test.e2e.ts
index e241b1186..b3c560039 100644
--- a/src/frontend/admin/src/tests/orders/orders-filters.test.e2e.ts
+++ b/src/frontend/admin/src/tests/orders/orders-filters.test.e2e.ts
@@ -89,7 +89,7 @@ test.describe("Order filters", () => {
.getByTestId("select-order-state-filter")
.getByLabel("State")
.click();
- await page.getByRole("option", { name: "Submitted" }).click();
+ await page.getByRole("option", { name: "Completed" }).click();
await page.getByTestId("custom-modal").getByLabel("Product").click();
await page.getByTestId("custom-modal").getByLabel("Product").fill("p");
@@ -107,7 +107,7 @@ test.describe("Order filters", () => {
await page.getByRole("option", { name: store.users[0].username }).click();
await page.getByLabel("close").click();
await expect(
- page.getByRole("button", { name: "State: Submitted" }),
+ page.getByRole("button", { name: "State: Completed" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: `Product: ${store.products[0].title}` }),
diff --git a/src/frontend/admin/src/tests/orders/orders.test.e2e.ts b/src/frontend/admin/src/tests/orders/orders.test.e2e.ts
index b31af8905..b8b7f1a8d 100644
--- a/src/frontend/admin/src/tests/orders/orders.test.e2e.ts
+++ b/src/frontend/admin/src/tests/orders/orders.test.e2e.ts
@@ -20,6 +20,7 @@ import {
import { ORGANIZATION_OPTIONS_REQUEST_RESULT } from "@/tests/mocks/organizations/organization-mock";
import { closeAllNotification, delay } from "@/components/testing/utils";
import { formatShortDateTest } from "@/tests/utils";
+import { orderStatesMessages } from "@/components/templates/orders/view/translations";
const url = "http://localhost:8071/api/v1.0/admin/orders/";
const catchIdRegex = getUrlCatchIdRegex(url);
@@ -34,7 +35,8 @@ test.describe("Order view", () => {
await page.route(catchIdRegex, async (route, request) => {
const methods = request.method();
if (methods === "GET") {
- await route.fulfill({ json: store.list[0] });
+ const id = request.url().match(catchIdRegex)?.[1];
+ await route.fulfill({ json: store.list.find((o) => o.id === id) });
}
});
@@ -54,7 +56,7 @@ test.describe("Order view", () => {
});
});
- test("Check all field are the good value", async ({ page }) => {
+ test("Check all fields have the good value", async ({ page }) => {
const order = store.list[0];
order.main_invoice.created_on = new Date(
Date.UTC(2024, 0, 23, 19, 30),
@@ -62,13 +64,6 @@ test.describe("Order view", () => {
order.main_invoice.updated_on = new Date(
Date.UTC(2024, 0, 23, 20, 30),
).toLocaleString("en-US");
- await page.unroute(catchIdRegex);
- await page.route(catchIdRegex, async (route, request) => {
- const methods = request.method();
- if (methods === "GET") {
- await route.fulfill({ json: store.list[0] });
- }
- });
await page.goto(PATH_ADMIN.orders.list);
await page.getByRole("heading", { name: "Orders" }).click();
await page.getByRole("link", { name: order.product.title }).click();
@@ -113,6 +108,42 @@ test.describe("Order view", () => {
order.certificate.definition_title,
);
}
+
+ await expect(
+ page.getByRole("heading", { name: "Payment schedule" }),
+ ).toBeVisible();
+ const paymentSchedule = order.payment_schedule;
+ if (paymentSchedule) {
+ await Promise.all(
+ paymentSchedule!.map(async (payment) => {
+ const paymentLocator = page.getByTestId(
+ `order-view-payment-${payment.id}`,
+ );
+ await page.pause();
+ await expect(paymentLocator).toBeVisible();
+ await expect(
+ paymentLocator.getByRole("cell", {
+ name: await formatShortDateTest(page, payment.due_date),
+ }),
+ ).toBeVisible();
+ await expect(
+ paymentLocator.getByRole("cell", {
+ name: payment.amount.toString() + " " + payment.currency,
+ }),
+ ).toBeVisible();
+ await expect(
+ paymentLocator.getByRole("cell", { name: payment.state }),
+ ).toBeVisible();
+ }),
+ );
+ }
+
+ if (order.credit_card) {
+ const creditCardLocator = page.getByTestId(
+ `credit-card-${order.credit_card!.id}`,
+ );
+ await expect(creditCardLocator).toBeVisible();
+ }
});
test("Check when organization is undefined", async ({ page }) => {
@@ -409,6 +440,27 @@ test.describe("Order view", () => {
page.getByText("The certificate has already been generated"),
).toBeVisible();
});
+
+ test("should display alert message when order has no credit card", async ({
+ page,
+ }) => {
+ const order = store.list[0];
+ order.credit_card = null;
+
+ await page.unroute(catchIdRegex);
+ await page.route(catchIdRegex, async (route, request) => {
+ const methods = request.method();
+ if (methods === "GET") {
+ await route.fulfill({ json: order });
+ }
+ });
+ await page.goto(PATH_ADMIN.orders.list);
+ await page.getByRole("link", { name: order.product.title }).click();
+
+ await expect(
+ page.getByRole("alert").getByText("No payment method has been defined."),
+ ).toBeVisible();
+ });
});
test.describe("Order list", () => {
@@ -461,7 +513,9 @@ test.describe("Order list", () => {
rowLocator.getByRole("gridcell", { name: order.product_title }),
).toBeVisible();
await expect(
- rowLocator.getByRole("gridcell", { name: order.state }),
+ rowLocator.getByRole("gridcell", {
+ name: orderStatesMessages[order.state].defaultMessage,
+ }),
).toBeVisible();
await expect(
rowLocator.getByRole("gridcell", {
diff --git a/src/frontend/admin/src/utils/numbers.spec.tsx b/src/frontend/admin/src/utils/numbers.spec.tsx
index 3df264ba5..db81ac58e 100644
--- a/src/frontend/admin/src/utils/numbers.spec.tsx
+++ b/src/frontend/admin/src/utils/numbers.spec.tsx
@@ -1,17 +1,32 @@
-import { randomNumber } from "@/utils/numbers";
+import { randomNumber, toDigitString } from "@/utils/numbers";
-describe("", () => {
- it("get random number between 0 and 3", async () => {
- let number = randomNumber(3);
- expect(number).toBeLessThanOrEqual(3);
+describe("utils/numbers", () => {
+ describe("randomNumber", () => {
+ it("get random number between 0 and 3", () => {
+ let number = randomNumber(3);
+ expect(number).toBeLessThanOrEqual(3);
- number = randomNumber(3);
- expect(number).toBeLessThanOrEqual(3);
+ number = randomNumber(3);
+ expect(number).toBeLessThanOrEqual(3);
- number = randomNumber(3);
- expect(number).toBeLessThanOrEqual(3);
+ number = randomNumber(3);
+ expect(number).toBeLessThanOrEqual(3);
- number = randomNumber(3);
- expect(number).toBeLessThanOrEqual(3);
+ number = randomNumber(3);
+ expect(number).toBeLessThanOrEqual(3);
+ });
+ });
+
+ describe("toDigitString", () => {
+ it("converts number to digit string", () => {
+ let digit = toDigitString(9);
+ expect(digit).toBe("09");
+
+ digit = toDigitString(10);
+ expect(digit).toBe("10");
+
+ digit = toDigitString(1_000_001);
+ expect(digit).toBe("1000001");
+ });
});
});
diff --git a/src/frontend/admin/src/utils/numbers.ts b/src/frontend/admin/src/utils/numbers.ts
index 171e8afa6..2f39df414 100644
--- a/src/frontend/admin/src/utils/numbers.ts
+++ b/src/frontend/admin/src/utils/numbers.ts
@@ -1,3 +1,11 @@
export const randomNumber = (max: number): number => {
return Math.floor(Math.random() * max) + 1;
};
+
+export const toDigitString = (value: number) => {
+ if (value >= 10) {
+ return value.toString();
+ }
+
+ return `0${value}`;
+};
diff --git a/src/mail/mjml/installment_paid.mjml b/src/mail/mjml/installment_paid.mjml
new file mode 100644
index 000000000..150780c05
--- /dev/null
+++ b/src/mail/mjml/installment_paid.mjml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+ {% blocktranslate with targeted_installment_index=targeted_installment_index|add:1|ordinal title=product_title %}
+ For the course {{ title }}, the {{ targeted_installment_index }}
+ installment has been successfully paid.
+
+ {% endblocktranslate %}
+
+
+
+
+
+
+ {% with installment_amount=installment_amount|format_currency_with_symbol remaining_balance_to_pay=remaining_balance_to_pay|format_currency_with_symbol date_next_installment_to_pay=date_next_installment_to_pay|date:"SHORT_DATE_FORMAT" %}
+ {% blocktranslate %}
+ An amount of {{ installment_amount }} has been debited on
+ the credit card •••• •••• •••• {{ credit_card_last_numbers }}.
+
+ Currently, it remains {{ remaining_balance_to_pay }} to be paid.
+ The next installment will be debited on {{ date_next_installment_to_pay }}.
+ {% endblocktranslate %}
+ {% endwith %}
+
+
+
+
+
+
+
+ {% blocktranslate %}
+ See order details on your dashboard
+ {% endblocktranslate %}
+
+
+
+
+
+
+
diff --git a/src/mail/mjml/installment_refused.mjml b/src/mail/mjml/installment_refused.mjml
new file mode 100644
index 000000000..77afb9651
--- /dev/null
+++ b/src/mail/mjml/installment_refused.mjml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+ {% blocktranslate with product_title=product_title installment_amount=installment_amount|format_currency_with_symbol targeted_installment_index=targeted_installment_index|add:1|ordinal title=product_title %}
+ For the course {{ title }}, the {{ targeted_installment_index }}
+ installment debit has failed.
+
+ We have tried to debit an amount of {{ installment_amount }}
+ on the credit card •••• •••• •••• {{ credit_card_last_numbers }}.
+ {% endblocktranslate %}
+
+
+
+
+
+
+
+ {% blocktranslate %}
+ Please correct the failed payment as soon as possible using
+ your dashboard.
+ {% endblocktranslate %}
+
+
+
+
+
+
+
diff --git a/src/mail/mjml/installment_reminder.mjml b/src/mail/mjml/installment_reminder.mjml
new file mode 100644
index 000000000..d60b0dc3c
--- /dev/null
+++ b/src/mail/mjml/installment_reminder.mjml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+ {% blocktranslate with installment_amount=installment_amount|format_currency_with_symbol targeted_installment_index=targeted_installment_index|add:1|ordinal title=product_title %}
+ For the course {{ title }}, the {{ targeted_installment_index }}
+ installment will be withdrawn on {{ days_until_debit }} days.
+
+ We will try to debit an amount of {{ installment_amount }} on the credit card
+ •••• •••• •••• {{ credit_card_last_numbers }}.
+ {% endblocktranslate %}
+
+
+
+
+
+
+
+ {% blocktranslate %}
+ See order details on your dashboard : dashboard
+ {% endblocktranslate %}
+
+
+
+
+
+
+
diff --git a/src/mail/mjml/installments_fully_paid.mjml b/src/mail/mjml/installments_fully_paid.mjml
new file mode 100644
index 000000000..cf3f521b3
--- /dev/null
+++ b/src/mail/mjml/installments_fully_paid.mjml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+ {% blocktranslate with title=product_title %}
+ For the course {{ title }}, we have just debited the last installment.
+ Your order is now fully paid!
+ {% endblocktranslate %}
+
+
+ s
+
+
+
+ {% with installment_amount=installment_amount|format_currency_with_symbol %}
+ {% blocktranslate %}
+ An amount of {{ installment_amount }} has been debited on
+ the credit card •••• •••• •••• {{ credit_card_last_numbers }}.
+
+ {% endblocktranslate %}
+ {% endwith %}
+
+
+
+
+
+
+
+ {% blocktranslate %}
+ See order details on your dashboard
+ {% endblocktranslate %}
+
+
+
+
+
+
+
diff --git a/src/mail/mjml/partial/header.mjml b/src/mail/mjml/partial/header.mjml
index 665a3c026..b5f12e5ad 100644
--- a/src/mail/mjml/partial/header.mjml
+++ b/src/mail/mjml/partial/header.mjml
@@ -5,7 +5,7 @@
We load django tags here, in this way there are put within the body in html output
so the html-to-text command includes it within its output
-->
- {% load i18n static extra_tags %}
+ {% load i18n humanize static extra_tags %}
{{ title }}
diff --git a/src/mail/mjml/partial/installment_table.mjml b/src/mail/mjml/partial/installment_table.mjml
new file mode 100644
index 000000000..3ad1cb90b
--- /dev/null
+++ b/src/mail/mjml/partial/installment_table.mjml
@@ -0,0 +1,63 @@
+
+
+
+ {% trans "Payment schedule" %}
+
+
+
+
+
+
+
+
+ {% for installment in order_payment_schedule %}
+ {% with amount=installment.amount|format_currency_with_symbol installment_date=installment.due_date|date:"SHORT_DATE_FORMAT" %}
+
+
+ {{ forloop.counter }}
+
+
+ {{ amount }}
+
+
+
+ {% blocktranslate with installment_date=installment_date %}
+ Withdrawn on {{ installment_date }}
+ {% endblocktranslate %}
+
+
+
+
+ {% if installment.state == "paid" %}
+
+ {% blocktranslate with state=installment.state.capitalize %}{{ state }}{% endblocktranslate %}
+
+ {% elif installment.state == "pending" %}
+
+ {% blocktranslate with state=installment.state.capitalize %}{{ state }}{% endblocktranslate %}
+
+ {% elif installment.state == "refused" %}
+
+ {% blocktranslate with state=installment.state.capitalize %}{{ state }}{% endblocktranslate %}
+
+ {% endif %}
+
+
+
+ {% endwith %}
+ {% endfor %}
+
+
+
+
+
+
+
+ Total
+ {{ product_price|format_currency_with_symbol }}
+