Skip to content

Commit 37b6575

Browse files
♻️(backend) refactor payment plan endpoint to skip contract inputs
Now that we different payment methods in our sales tunnel such as credit card, external deep links, and batch orders. Some payment methods like orders of batch order and prepaid orders from external platform, do not require to have a contract signed or a payment schedule because they are both fully prepaid. We have changed the key in the payment plan endpoint, to return whether the API consumer should skip the contract input or not.
1 parent fcb6317 commit 37b6575

4 files changed

Lines changed: 141 additions & 20 deletions

File tree

src/backend/joanie/core/api/client/__init__.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from django.core.exceptions import ValidationError
1313
from django.core.files.storage import storages
1414
from django.db import transaction
15-
from django.db.models import Exists, OuterRef, Prefetch, Subquery
15+
from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery
1616
from django.http import FileResponse, Http404, HttpResponse, JsonResponse
1717
from django.urls import reverse
1818
from django.utils import timezone
@@ -276,7 +276,7 @@ def payment_plan(self, request, *args, **kwargs):
276276
voucher_code = self._get_voucher_code(request)
277277
price = self._get_price(offering, voucher_code)
278278
# Get the discount value if one is set
279-
discount, from_batch_order = self._get_discount(offering, voucher_code)
279+
discount, skip_contract_inputs = self._get_discount(offering, voucher_code)
280280

281281
serializer = self.get_serializer(
282282
data={
@@ -289,7 +289,7 @@ def payment_plan(self, request, *args, **kwargs):
289289
"price": offering.product.price,
290290
"discount": discount,
291291
"discounted_price": price if discount else None,
292-
"from_batch_order": from_batch_order,
292+
"skip_contract_inputs": skip_contract_inputs,
293293
}
294294
)
295295
serializer.is_valid(raise_exception=True)
@@ -318,16 +318,15 @@ def _get_discount(self, offering, voucher_code):
318318
if voucher_code:
319319
voucher = get_object_or_404(
320320
models.Voucher.objects.only("discount").annotate(
321-
from_batch_order=Exists(
322-
models.Order.objects.filter(
323-
voucher=OuterRef("pk"),
324-
batch_order__isnull=False,
321+
skip_contract_inputs=Exists(
322+
models.Order.objects.filter(voucher=OuterRef("pk")).filter(
323+
Q(batch_order__isnull=False) | Q(contract__isnull=True),
325324
)
326325
)
327326
),
328327
code=voucher_code,
329328
)
330-
return str(voucher.discount), voucher.from_batch_order
329+
return str(voucher.discount), voucher.skip_contract_inputs
331330

332331
return offering.rules.get("discount", None), False
333332

src/backend/joanie/core/serializers/client.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1330,9 +1330,11 @@ class OrderPaymentScheduleSerializer(serializers.Serializer):
13301330
required=False,
13311331
help_text=_("Discounted price of the offer."),
13321332
)
1333-
from_batch_order = serializers.BooleanField(
1333+
skip_contract_inputs = serializers.BooleanField(
13341334
required=False,
1335-
help_text=_("If the order was created from a batch order."),
1335+
help_text=_(
1336+
"Orders that do not require contract inputs either from batch order or standalone."
1337+
),
13361338
)
13371339

13381340
def create(self, validated_data):

src/backend/joanie/tests/core/test_api_offerings.py

Lines changed: 128 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1896,7 +1896,7 @@ def test_api_offering_payment_plan_with_product_id(
18961896
"state": enums.PAYMENT_STATE_PENDING,
18971897
},
18981898
],
1899-
"from_batch_order": False,
1899+
"skip_contract_inputs": False,
19001900
},
19011901
response.json(),
19021902
)
@@ -1985,7 +1985,7 @@ def test_api_offering_payment_plan_with_product_id_discount(
19851985
"state": enums.PAYMENT_STATE_PENDING,
19861986
},
19871987
],
1988-
"from_batch_order": False,
1988+
"skip_contract_inputs": False,
19891989
},
19901990
response.json(),
19911991
)
@@ -2052,7 +2052,7 @@ def test_api_offering_payment_plan_with_certificate_product_id(
20522052
"state": enums.PAYMENT_STATE_PENDING,
20532053
},
20542054
],
2055-
"from_batch_order": False,
2055+
"skip_contract_inputs": False,
20562056
},
20572057
response.json(),
20582058
)
@@ -2123,7 +2123,7 @@ def test_api_offering_payment_plan_with_certificate_product_id_discount(
21232123
"state": enums.PAYMENT_STATE_PENDING,
21242124
},
21252125
],
2126-
"from_batch_order": False,
2126+
"skip_contract_inputs": False,
21272127
},
21282128
response.json(),
21292129
)
@@ -2267,7 +2267,7 @@ def test_api_offering_payment_plan_voucher_anonymous(
22672267
"state": enums.PAYMENT_STATE_PENDING,
22682268
},
22692269
],
2270-
"from_batch_order": False,
2270+
"skip_contract_inputs": False,
22712271
},
22722272
response.json(),
22732273
)
@@ -2381,7 +2381,7 @@ def test_api_offering_payment_plan_voucher_with_credential_product_id_with_vouch
23812381
"state": enums.PAYMENT_STATE_PENDING,
23822382
},
23832383
],
2384-
"from_batch_order": False,
2384+
"skip_contract_inputs": False,
23852385
},
23862386
response.json(),
23872387
)
@@ -2481,7 +2481,7 @@ def test_api_offering_payment_plan_voucher_with_certificate_product_id_with_vouc
24812481
"state": enums.PAYMENT_STATE_PENDING,
24822482
},
24832483
],
2484-
"from_batch_order": False,
2484+
"skip_contract_inputs": False,
24852485
},
24862486
response.json(),
24872487
)
@@ -2574,7 +2574,127 @@ def test_api_offering_payment_plan_voucher_code_from_batch_order(
25742574
"state": enums.PAYMENT_STATE_PENDING,
25752575
},
25762576
],
2577-
"from_batch_order": True,
2577+
"skip_contract_inputs": True,
2578+
},
2579+
response.json(),
2580+
)
2581+
2582+
self.assertStatusCodeEqual(response_relation_path, HTTPStatus.OK)
2583+
self.assertEqual(response_relation_path.json(), response.json())
2584+
self.assertStatusCodeEqual(response_with_query_params, HTTPStatus.OK)
2585+
self.assertEqual(response_with_query_params.json(), response.json())
2586+
self.assertStatusCodeEqual(
2587+
response_relation_path_with_query_param,
2588+
HTTPStatus.OK,
2589+
)
2590+
self.assertEqual(
2591+
response_relation_path_with_query_param.json(), response.json()
2592+
)
2593+
2594+
@override_settings(
2595+
JOANIE_PAYMENT_SCHEDULE_LIMITS={
2596+
100: (100,),
2597+
},
2598+
DEFAULT_CURRENCY="EUR",
2599+
)
2600+
@patch(
2601+
"joanie.core.api.client.ValidateVoucherThrottle.get_rate",
2602+
return_value="5/minute",
2603+
)
2604+
def test_api_offering_payment_plan_voucher_code_for_deep_link_prepaid_order(
2605+
self,
2606+
_mock_get_rate,
2607+
):
2608+
"""
2609+
The endpoint `payment_plan` should return True to skip contract inputs
2610+
when the order is in state `to_own` and is fully prepaid and is not related
2611+
to a batch order.
2612+
"""
2613+
user = factories.UserFactory()
2614+
token = self.generate_token_from_user(user)
2615+
2616+
course = factories.CourseFactory()
2617+
course_run = factories.CourseRunFactory(
2618+
enrollment_start=datetime(2025, 1, 1, 14, tzinfo=ZoneInfo("UTC")),
2619+
start=datetime(2025, 3, 1, 14, tzinfo=ZoneInfo("UTC")),
2620+
end=datetime(2025, 5, 1, 14, tzinfo=ZoneInfo("UTC")),
2621+
course=course,
2622+
)
2623+
product = factories.ProductFactory(
2624+
price=10,
2625+
type=enums.PRODUCT_TYPE_CREDENTIAL,
2626+
target_courses=[course_run.course],
2627+
)
2628+
offering = factories.OfferingFactory(
2629+
course=course_run.course,
2630+
product=product,
2631+
)
2632+
# Create the order in draft state first (no owner, no batch_order)
2633+
order = factories.OrderGeneratorFactory(
2634+
product=offering.product,
2635+
course=offering.course,
2636+
organization=offering.organizations.first(),
2637+
owner=None,
2638+
credit_card=None,
2639+
state=enums.ORDER_STATE_DRAFT,
2640+
)
2641+
voucher = factories.VoucherFactory(
2642+
discount=factories.DiscountFactory(rate=1),
2643+
multiple_use=False,
2644+
multiple_users=False,
2645+
)
2646+
# Simulate what the admin endpoint does: attach voucher and force to_own
2647+
models.Order.objects.filter(pk=order.pk).update(
2648+
state=enums.ORDER_STATE_TO_OWN,
2649+
voucher=voucher,
2650+
batch_order=None,
2651+
)
2652+
2653+
mocked_now = datetime(2025, 1, 1, 0, tzinfo=ZoneInfo("UTC"))
2654+
payload = {"voucher_code": voucher.code}
2655+
2656+
with (
2657+
mock.patch("uuid.uuid4", return_value=uuid.UUID(int=1)),
2658+
mock.patch("django.utils.timezone.now", return_value=mocked_now),
2659+
):
2660+
response = self.client.get(
2661+
f"/api/v1.0/courses/{offering.course.code}/"
2662+
f"products/{offering.product.id}/payment-plan/",
2663+
HTTP_AUTHORIZATION=f"Bearer {token}",
2664+
data=payload,
2665+
)
2666+
response_relation_path = self.client.get(
2667+
f"/api/v1.0/offerings/{offering.id}/payment-plan/",
2668+
HTTP_AUTHORIZATION=f"Bearer {token}",
2669+
data=payload,
2670+
)
2671+
response_with_query_params = self.client.get(
2672+
f"/api/v1.0/courses/{offering.course.code}/"
2673+
f"products/{offering.product.id}/payment-plan/?voucher_code={voucher.code}",
2674+
HTTP_AUTHORIZATION=f"Bearer {token}",
2675+
)
2676+
response_relation_path_with_query_param = self.client.get(
2677+
f"/api/v1.0/offerings/{offering.id}/payment-plan/"
2678+
f"?voucher_code={voucher.code}",
2679+
HTTP_AUTHORIZATION=f"Bearer {token}",
2680+
)
2681+
2682+
self.assertStatusCodeEqual(response, HTTPStatus.OK)
2683+
self.assertEqual(
2684+
{
2685+
"price": 10.00,
2686+
"discount": "-100%",
2687+
"discounted_price": 0.00,
2688+
"payment_schedule": [
2689+
{
2690+
"id": "00000000-0000-0000-0000-000000000001",
2691+
"amount": 0.00,
2692+
"currency": settings.DEFAULT_CURRENCY,
2693+
"due_date": "2025-01-17",
2694+
"state": enums.PAYMENT_STATE_PENDING,
2695+
},
2696+
],
2697+
"skip_contract_inputs": True,
25782698
},
25792699
response.json(),
25802700
)

src/backend/joanie/tests/swagger/swagger.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8258,9 +8258,9 @@
82588258
"nullable": true,
82598259
"description": "Discounted price of the offer."
82608260
},
8261-
"from_batch_order": {
8261+
"skip_contract_inputs": {
82628262
"type": "boolean",
8263-
"description": "If the order was created from a batch order."
8263+
"description": "Orders that do not require contract inputs either from batch order or standalone."
82648264
}
82658265
},
82668266
"required": [

0 commit comments

Comments
 (0)