diff --git a/src/backend/joanie/core/api/client/__init__.py b/src/backend/joanie/core/api/client/__init__.py index c4d82b35a..e910e7a62 100755 --- a/src/backend/joanie/core/api/client/__init__.py +++ b/src/backend/joanie/core/api/client/__init__.py @@ -12,7 +12,7 @@ from django.core.exceptions import ValidationError from django.core.files.storage import storages from django.db import transaction -from django.db.models import Exists, OuterRef, Prefetch, Subquery +from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery from django.http import FileResponse, Http404, HttpResponse, JsonResponse from django.urls import reverse from django.utils import timezone @@ -276,7 +276,7 @@ def payment_plan(self, request, *args, **kwargs): voucher_code = self._get_voucher_code(request) price = self._get_price(offering, voucher_code) # Get the discount value if one is set - discount, from_batch_order = self._get_discount(offering, voucher_code) + discount, skip_contract_inputs = self._get_discount(offering, voucher_code) serializer = self.get_serializer( data={ @@ -289,7 +289,7 @@ def payment_plan(self, request, *args, **kwargs): "price": offering.product.price, "discount": discount, "discounted_price": price if discount else None, - "from_batch_order": from_batch_order, + "skip_contract_inputs": skip_contract_inputs, } ) serializer.is_valid(raise_exception=True) @@ -318,16 +318,15 @@ def _get_discount(self, offering, voucher_code): if voucher_code: voucher = get_object_or_404( models.Voucher.objects.only("discount").annotate( - from_batch_order=Exists( - models.Order.objects.filter( - voucher=OuterRef("pk"), - batch_order__isnull=False, + skip_contract_inputs=Exists( + models.Order.objects.filter(voucher=OuterRef("pk")).filter( + Q(batch_order__isnull=False) | Q(contract__isnull=True), ) ) ), code=voucher_code, ) - return str(voucher.discount), voucher.from_batch_order + return str(voucher.discount), voucher.skip_contract_inputs return offering.rules.get("discount", None), False diff --git a/src/backend/joanie/core/serializers/client.py b/src/backend/joanie/core/serializers/client.py index 3de7920bd..2b9eb3bb8 100644 --- a/src/backend/joanie/core/serializers/client.py +++ b/src/backend/joanie/core/serializers/client.py @@ -1330,9 +1330,11 @@ class OrderPaymentScheduleSerializer(serializers.Serializer): required=False, help_text=_("Discounted price of the offer."), ) - from_batch_order = serializers.BooleanField( + skip_contract_inputs = serializers.BooleanField( required=False, - help_text=_("If the order was created from a batch order."), + help_text=_( + "Orders that do not require contract inputs either from batch order or standalone." + ), ) def create(self, validated_data): diff --git a/src/backend/joanie/tests/core/test_api_offerings.py b/src/backend/joanie/tests/core/test_api_offerings.py index 04c8c916c..05b2b2ec4 100644 --- a/src/backend/joanie/tests/core/test_api_offerings.py +++ b/src/backend/joanie/tests/core/test_api_offerings.py @@ -1896,7 +1896,7 @@ def test_api_offering_payment_plan_with_product_id( "state": enums.PAYMENT_STATE_PENDING, }, ], - "from_batch_order": False, + "skip_contract_inputs": False, }, response.json(), ) @@ -1985,7 +1985,7 @@ def test_api_offering_payment_plan_with_product_id_discount( "state": enums.PAYMENT_STATE_PENDING, }, ], - "from_batch_order": False, + "skip_contract_inputs": False, }, response.json(), ) @@ -2052,7 +2052,7 @@ def test_api_offering_payment_plan_with_certificate_product_id( "state": enums.PAYMENT_STATE_PENDING, }, ], - "from_batch_order": False, + "skip_contract_inputs": False, }, response.json(), ) @@ -2123,7 +2123,7 @@ def test_api_offering_payment_plan_with_certificate_product_id_discount( "state": enums.PAYMENT_STATE_PENDING, }, ], - "from_batch_order": False, + "skip_contract_inputs": False, }, response.json(), ) @@ -2267,7 +2267,7 @@ def test_api_offering_payment_plan_voucher_anonymous( "state": enums.PAYMENT_STATE_PENDING, }, ], - "from_batch_order": False, + "skip_contract_inputs": False, }, response.json(), ) @@ -2381,7 +2381,7 @@ def test_api_offering_payment_plan_voucher_with_credential_product_id_with_vouch "state": enums.PAYMENT_STATE_PENDING, }, ], - "from_batch_order": False, + "skip_contract_inputs": False, }, response.json(), ) @@ -2481,7 +2481,7 @@ def test_api_offering_payment_plan_voucher_with_certificate_product_id_with_vouc "state": enums.PAYMENT_STATE_PENDING, }, ], - "from_batch_order": False, + "skip_contract_inputs": False, }, response.json(), ) @@ -2574,7 +2574,127 @@ def test_api_offering_payment_plan_voucher_code_from_batch_order( "state": enums.PAYMENT_STATE_PENDING, }, ], - "from_batch_order": True, + "skip_contract_inputs": True, + }, + response.json(), + ) + + self.assertStatusCodeEqual(response_relation_path, HTTPStatus.OK) + self.assertEqual(response_relation_path.json(), response.json()) + self.assertStatusCodeEqual(response_with_query_params, HTTPStatus.OK) + self.assertEqual(response_with_query_params.json(), response.json()) + self.assertStatusCodeEqual( + response_relation_path_with_query_param, + HTTPStatus.OK, + ) + self.assertEqual( + response_relation_path_with_query_param.json(), response.json() + ) + + @override_settings( + JOANIE_PAYMENT_SCHEDULE_LIMITS={ + 100: (100,), + }, + DEFAULT_CURRENCY="EUR", + ) + @patch( + "joanie.core.api.client.ValidateVoucherThrottle.get_rate", + return_value="5/minute", + ) + def test_api_offering_payment_plan_voucher_code_for_deep_link_prepaid_order( + self, + _mock_get_rate, + ): + """ + The endpoint `payment_plan` should return True to skip contract inputs + when the order is in state `to_own` and is fully prepaid and is not related + to a batch order. + """ + user = factories.UserFactory() + token = self.generate_token_from_user(user) + + course = factories.CourseFactory() + course_run = factories.CourseRunFactory( + enrollment_start=datetime(2025, 1, 1, 14, tzinfo=ZoneInfo("UTC")), + start=datetime(2025, 3, 1, 14, tzinfo=ZoneInfo("UTC")), + end=datetime(2025, 5, 1, 14, tzinfo=ZoneInfo("UTC")), + course=course, + ) + product = factories.ProductFactory( + price=10, + type=enums.PRODUCT_TYPE_CREDENTIAL, + target_courses=[course_run.course], + ) + offering = factories.OfferingFactory( + course=course_run.course, + product=product, + ) + # Create the order in draft state first (no owner, no batch_order) + order = factories.OrderGeneratorFactory( + product=offering.product, + course=offering.course, + organization=offering.organizations.first(), + owner=None, + credit_card=None, + state=enums.ORDER_STATE_DRAFT, + ) + voucher = factories.VoucherFactory( + discount=factories.DiscountFactory(rate=1), + multiple_use=False, + multiple_users=False, + ) + # Simulate what the admin endpoint does: attach voucher and force to_own + models.Order.objects.filter(pk=order.pk).update( + state=enums.ORDER_STATE_TO_OWN, + voucher=voucher, + batch_order=None, + ) + + mocked_now = datetime(2025, 1, 1, 0, tzinfo=ZoneInfo("UTC")) + payload = {"voucher_code": voucher.code} + + with ( + mock.patch("uuid.uuid4", return_value=uuid.UUID(int=1)), + mock.patch("django.utils.timezone.now", return_value=mocked_now), + ): + response = self.client.get( + f"/api/v1.0/courses/{offering.course.code}/" + f"products/{offering.product.id}/payment-plan/", + HTTP_AUTHORIZATION=f"Bearer {token}", + data=payload, + ) + response_relation_path = self.client.get( + f"/api/v1.0/offerings/{offering.id}/payment-plan/", + HTTP_AUTHORIZATION=f"Bearer {token}", + data=payload, + ) + response_with_query_params = self.client.get( + f"/api/v1.0/courses/{offering.course.code}/" + f"products/{offering.product.id}/payment-plan/?voucher_code={voucher.code}", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + response_relation_path_with_query_param = self.client.get( + f"/api/v1.0/offerings/{offering.id}/payment-plan/" + f"?voucher_code={voucher.code}", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + self.assertStatusCodeEqual(response, HTTPStatus.OK) + self.assertEqual( + { + "price": 10.00, + "discount": "-100%", + "discounted_price": 0.00, + "payment_schedule": [ + { + "id": "00000000-0000-0000-0000-000000000001", + "amount": 0.00, + "currency": settings.DEFAULT_CURRENCY, + "due_date": "2025-01-17", + "state": enums.PAYMENT_STATE_PENDING, + }, + ], + "skip_contract_inputs": True, }, response.json(), ) diff --git a/src/backend/joanie/tests/swagger/swagger.json b/src/backend/joanie/tests/swagger/swagger.json index 65d82c5b9..66c3b1c80 100644 --- a/src/backend/joanie/tests/swagger/swagger.json +++ b/src/backend/joanie/tests/swagger/swagger.json @@ -8258,9 +8258,9 @@ "nullable": true, "description": "Discounted price of the offer." }, - "from_batch_order": { + "skip_contract_inputs": { "type": "boolean", - "description": "If the order was created from a batch order." + "description": "Orders that do not require contract inputs either from batch order or standalone." } }, "required": [