diff --git a/.circleci/config.yml b/.circleci/config.yml index 205284938..48293d09c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -12,7 +12,7 @@ generate-version-file: &generate-version-file "$CIRCLE_PROJECT_REPONAME" \ "$CIRCLE_BUILD_URL" > src/backend/joanie/version.json -version: 2 +version: 2.1 jobs: # Git jobs # Check that the git history is clean and complies with our expectations @@ -158,9 +158,21 @@ jobs: - run: name: Lint code with ruff command: ~/.local/bin/ruff check joanie - - run: - name: Lint code with pylint - command: ~/.local/bin/pylint joanie + - when: + condition: + not: + matches: { pattern: "^dev_/.+$", value: << pipeline.git.branch >> } + steps: + - run: + name: Lint code with pylint + command: ~/.local/bin/pylint joanie + - when: + condition: + matches: { pattern: "^dev_/.+$", value: << pipeline.git.branch >> } + steps: + - run: + name: Lint code with pylint, ignoring TODOs + command: ~/.local/bin/pylint joanie --disable=fixme test-back: docker: diff --git a/CHANGELOG.md b/CHANGELOG.md index 090e9eefc..0fb831b7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,16 +8,44 @@ and this project adheres to ## [Unreleased] +### Added + +- Signature backend can now retrieve the signing progression of a document +- Debit installment on pending order transition if due date is on current day +- Display order credit card detail in the back office +- Send an email reminder to the user when an installment + will be debited on his credit card on his order's payment schedule +- Send an email to the user when an installment debit has been + refused +- Send an email to the user when an installment is successfully + paid +- Support of payment_schedule for certificate products + ### Changed -- Update round robin logic to favor author organizations -- Reassign organization for pending orders +- Updated `OrderPaymentScheduleDecoder` to return a `date` object for + the `due_date` attribute and a `Money` object for `amount` attribute + in the payment_schedule, instead of string values +- Bind payment_schedule into `OrderLightSerializer` +- Generate payment schedule for any kind of product +- Sort credit card list by is_main then descending creation date +- Rework order statuses +- Update the task `debit_pending_installment` to catch up on late + payments of installments that are in the past +- Deprecated field `has_consent_to_terms` for `Order` model ### Fixed - Improve signature backend `handle_notification` error catching +- Prevent duplicate Address objects for a user or an organization - Allow to cancel an enrollment order linked to an archived course run +### Removed + +- Remove the `has_consent_to_terms` field from the `Order` edit view + in the back office application + + ## [2.6.1] - 2024-07-25 ### Fixed @@ -50,7 +78,6 @@ and this project adheres to - Do not update OpenEdX enrollment if this one is already up-to-date on the remote lms -- ## [2.4.0] - 2024-06-21 diff --git a/Makefile b/Makefile index 91c3ebcc2..96553c312 100644 --- a/Makefile +++ b/Makefile @@ -175,11 +175,21 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f bin/pylint --diff-only=origin/main .PHONY: lint-pylint +lint-pylint-todo: ## lint back-end python sources with pylint only on changed files from main without fixme warnings + @echo 'lint:pylint started…' + bin/pylint --diff-only=origin/main --disable=fixme +.PHONY: lint-pylint-todo + lint-pylint-all: ## lint back-end python sources with pylint @echo 'lint:pylint-all started…' bin/pylint joanie .PHONY: lint-pylint-all +lint-pylint-all-todo: ## lint back-end python sources with pylint without fixme warnings + @echo 'lint:pylint-all started…' + bin/pylint joanie --disable=fixme +.PHONY: lint-pylint-all-todo + test: ## run project tests @$(MAKE) test-back-parallel @$(MAKE) admin-test diff --git a/env.d/development/common.dist b/env.d/development/common.dist index 838f4f7c4..607551fd1 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -57,7 +57,7 @@ DJANGO_EMAIL_PORT=1025 # Richie JOANIE_CATALOG_BASE_URL=http://richie:8070 JOANIE_CATALOG_NAME=richie -JOANIE_CONTRACT_CONTEXT_PROCESSORS = +JOANIE_CONTRACT_CONTEXT_PROCESSORS = # Backoffice JOANIE_BACKOFFICE_BASE_URL="http://localhost:8072" @@ -75,3 +75,6 @@ DEVELOPER_EMAIL="developer@example.com" # Security for remote endpoints API JOANIE_AUTHORIZED_API_TOKENS = "secretTokenForRemoteAPIConsumer" + +# Add here the dashboard link of orders for email sent when an installment is paid +JOANIE_DASHBOARD_ORDER_LINK = "http://localhost:8070/dashboard/courses/orders/:orderId/" diff --git a/src/backend/joanie/core/admin.py b/src/backend/joanie/core/admin.py index 6029bbe54..7324f4c89 100644 --- a/src/backend/joanie/core/admin.py +++ b/src/backend/joanie/core/admin.py @@ -583,7 +583,6 @@ class OrderAdmin(DjangoObjectActions, admin.ModelAdmin): readonly_fields = ( "state", "total", - "has_consent_to_terms", "invoice", "certificate", ) diff --git a/src/backend/joanie/core/api/admin/__init__.py b/src/backend/joanie/core/api/admin/__init__.py index 46ae7f20d..846b78ac6 100755 --- a/src/backend/joanie/core/api/admin/__init__.py +++ b/src/backend/joanie/core/api/admin/__init__.py @@ -596,6 +596,7 @@ class OrderViewSet( "certificate", "certificate__certificate_definition", "order_group", + "credit_card", ) filter_backends = [DjangoFilterBackend, OrderingFilter] ordering_fields = ["created_on"] diff --git a/src/backend/joanie/core/api/client/__init__.py b/src/backend/joanie/core/api/client/__init__.py index 84ad8f2e4..f721ef634 100644 --- a/src/backend/joanie/core/api/client/__init__.py +++ b/src/backend/joanie/core/api/client/__init__.py @@ -2,7 +2,8 @@ Client API endpoints """ -# pylint: disable=too-many-ancestors, too-many-lines +# pylint: disable=too-many-ancestors, too-many-lines, too-many-branches +# ruff: noqa: PLR0911,PLR0912 import io import uuid from http import HTTPStatus @@ -10,18 +11,7 @@ from django.core.exceptions import ValidationError from django.core.files.storage import storages from django.db import IntegrityError, transaction -from django.db.models import ( - BooleanField, - Case, - Count, - ExpressionWrapper, - OuterRef, - Prefetch, - Q, - Subquery, - Value, - When, -) +from django.db.models import Count, OuterRef, Prefetch, Q, Subquery from django.http import FileResponse, Http404, HttpResponse, JsonResponse from django.urls import reverse from django.utils import timezone @@ -39,6 +29,7 @@ from joanie.core import enums, filters, models, permissions, serializers from joanie.core.api.base import NestedGenericViewSet from joanie.core.exceptions import NoContractToSignError +from joanie.core.models import Address from joanie.core.tasks import generate_zip_archive_task from joanie.core.utils import contract as contract_utility from joanie.core.utils import contract_definition, issuers @@ -211,9 +202,13 @@ def payment_schedule(self, *args, **kwargs): Return the payment schedule for a course product relation. """ course_product_relation = self.get_object() - course_run_dates = ( - course_product_relation.product.get_equivalent_course_run_dates() - ) + + if course_product_relation.product.type == enums.PRODUCT_TYPE_CERTIFICATE: + instance = course_product_relation.course + else: + instance = course_product_relation.product + course_run_dates = instance.get_equivalent_course_run_dates() + payment_schedule = generate_payment_schedule( course_product_relation.product.price, timezone.now(), @@ -334,35 +329,31 @@ def _get_organization_with_least_active_orders( Return the organization with the least not canceled order count for a given product and course. """ - course_id = course.id if course else enrollment.course_run.course_id + if enrollment: + clause = Q(order__enrollment=enrollment) + else: + clause = Q(order__course=course) + + order_count = Count( + "order", + filter=clause + & Q(order__product=product) + & ~Q(order__state=enums.ORDER_STATE_CANCELED), + ) try: - course_relation = product.course_relations.get(course_id=course_id) + course_relation = product.course_relations.get( + course_id=course.id if course else enrollment.course_run.course_id + ) except models.CourseProductRelation.DoesNotExist: return None - order_count_filter = Q(order__product=product) & ~Q( - order__state__in=[ - enums.ORDER_STATE_CANCELED, - enums.ORDER_STATE_PENDING, - ] - ) - if enrollment: - order_count_filter &= Q(order__enrollment=enrollment) - else: - order_count_filter &= Q(order__course=course) - try: - organizations = course_relation.organizations.annotate( - order_count=Count("order", filter=order_count_filter), - is_author=Case( - When(Q(courses__id=course_id), then=Value(True)), - default=Value(False), - output_field=BooleanField(), - ), + return ( + course_relation.organizations.annotate(order_count=order_count) + .order_by("order_count") + .first() ) - - return organizations.order_by("order_count", "-is_author", "?").first() except models.Organization.DoesNotExist: return None @@ -412,6 +403,19 @@ def create(self, request, *args, **kwargs): ) course = enrollment.course_run.course + if not serializer.initial_data.get("organization_id"): + organization = self._get_organization_with_least_active_orders( + product, course, enrollment + ) + if organization: + serializer.initial_data["organization_id"] = organization.id + + if product.price != 0 and not request.data.get("billing_address"): + return Response( + {"billing_address": "This field is required."}, + status=HTTPStatus.BAD_REQUEST, + ) + # - Validate data then create an order try: self.perform_create(serializer) @@ -424,61 +428,21 @@ def create(self, request, *args, **kwargs): status=HTTPStatus.BAD_REQUEST, ) - # Else return the fresh new order - return Response(serializer.data, status=HTTPStatus.CREATED) - - @action(detail=True, methods=["PATCH"]) - def submit(self, request, pk=None): # pylint: disable=no-self-use, invalid-name, unused-argument - """ - Submit a draft order if the conditions are filled - """ - billing_address = ( - models.Address(**request.data.get("billing_address")) - if request.data.get("billing_address") - else None - ) - credit_card_id = request.data.get("credit_card_id") - order = self.get_object() - - # If the order is in pending state, we want to reaffect an organization - # when the order is resubmit. This is a temporary fix to prevent to - # create a migration on the main branch. - if order.organization is None or order.state == enums.ORDER_STATE_PENDING: - order.organization = self._get_organization_with_least_active_orders( - order.product, order.course, order.enrollment - ) - order.save() - - return Response( - {"payment_info": order.submit(billing_address, credit_card_id)}, - status=HTTPStatus.CREATED, + serializer.instance.init_flow( + billing_address=request.data.get("billing_address") ) - @action(detail=True, methods=["POST"]) - def abort(self, request, pk=None): # pylint: disable=no-self-use, invalid-name, unused-argument - """Change the state of the order to pending""" - payment_id = request.data.get("payment_id") - - order = self.get_object() - - if order.state == enums.ORDER_STATE_VALIDATED: - return Response( - "Cannot abort a validated order.", - status=HTTPStatus.UNPROCESSABLE_ENTITY, - ) - - order.flow.pending(payment_id) - - return Response(status=HTTPStatus.NO_CONTENT) + # Else return the fresh new order + return Response(serializer.data, status=HTTPStatus.CREATED) @action(detail=True, methods=["POST"]) def cancel(self, request, pk=None): # pylint: disable=no-self-use, invalid-name, unused-argument """Change the state of the order to cancelled""" order = self.get_object() - if order.state == enums.ORDER_STATE_VALIDATED: + if order.state == enums.ORDER_STATE_COMPLETED: return Response( - "Cannot cancel a validated order.", + "Cannot cancel a completed order.", status=HTTPStatus.UNPROCESSABLE_ENTITY, ) @@ -526,15 +490,6 @@ def invoice(self, request, pk=None): # pylint: disable=no-self-use, invalid-nam return response - @action(detail=True, methods=["PUT"]) - def validate(self, request, pk=None): # pylint: disable=no-self-use, invalid-name, unused-argument - """ - Validate the order - """ - order = self.get_object() - order.flow.validate() - return Response(status=HTTPStatus.OK) - @extend_schema(request=None) @action(detail=True, methods=["POST"]) def submit_for_signature(self, request, pk=None): # pylint: disable=no-self-use, unused-argument, invalid-name @@ -630,6 +585,45 @@ def submit_installment_payment(self, request, pk=None): # pylint: disable=unuse return Response(payment_infos, status=HTTPStatus.OK) + @extend_schema( + request={"credit_card_id": OpenApiTypes.UUID}, + responses={ + (200, "application/json"): OpenApiTypes.OBJECT, + 400: serializers.ErrorResponseSerializer, + 404: serializers.ErrorResponseSerializer, + }, + ) + @action(detail=True, methods=["POST"], url_path="payment-method") + def payment_method(self, request, *args, **kwargs): + """ + Set the payment method for an order. + """ + order = self.get_object() + + credit_card_id = request.data.get("credit_card_id") + if not credit_card_id: + return Response( + {"credit_card_id": "This field is required."}, + status=HTTPStatus.BAD_REQUEST, + ) + + try: + credit_card = CreditCard.objects.get_card_for_owner( + pk=credit_card_id, + username=order.owner.username, + ) + except CreditCard.DoesNotExist: + return Response( + {"detail": "Credit card does not exist."}, + status=HTTPStatus.NOT_FOUND, + ) + + order.credit_card = credit_card + order.save() + order.flow.update() + + return Response(status=HTTPStatus.CREATED) + class AddressViewSet( mixins.ListModelMixin, @@ -1193,8 +1187,8 @@ class GenericContractViewSet( serializer_class = serializers.ContractSerializer filterset_class = filters.ContractViewSetFilter ordering = ["-student_signed_on", "-created_on"] - queryset = models.Contract.objects.filter( - order__state=enums.ORDER_STATE_VALIDATED + queryset = models.Contract.objects.exclude( + order__state=enums.ORDER_STATE_CANCELED ).select_related( "definition", "order__organization", @@ -1254,10 +1248,8 @@ def download(self, request, pk=None): # pylint: disable=unused-argument, invali """ contract = self.get_object() - if contract.order.state != enums.ORDER_STATE_VALIDATED: - raise ValidationError( - "Cannot get contract when an order is not yet validated." - ) + if contract.order.state == enums.ORDER_STATE_CANCELED: + raise ValidationError("Cannot get contract when an order is cancelled.") if not contract.is_fully_signed: raise ValidationError( @@ -1544,7 +1536,9 @@ class NestedOrderCourseViewSet(NestedGenericViewSet, mixins.ListModelMixin): filterset_class = filters.NestedOrderCourseViewSetFilter ordering = ["-created_on"] queryset = ( - models.Order.objects.filter(state=enums.ORDER_STATE_VALIDATED) + models.Order.objects.filter( + state__in=enums.ORDER_STATES_BINDING, + ) .select_related( "contract", "certificate", diff --git a/src/backend/joanie/core/enums.py b/src/backend/joanie/core/enums.py index 4e60c79c8..2f81c02f2 100644 --- a/src/backend/joanie/core/enums.py +++ b/src/backend/joanie/core/enums.py @@ -58,10 +58,14 @@ ) 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 being signed 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 @@ -69,13 +73,12 @@ ORDER_STATE_CHOICES = ( (ORDER_STATE_DRAFT, _("Draft")), # default - (ORDER_STATE_SUBMITTED, _("Submitted")), + (ORDER_STATE_ASSIGNED, _("Assigned")), + (ORDER_STATE_TO_SAVE_PAYMENT_METHOD, _("To save payment method")), + (ORDER_STATE_TO_SIGN, _("To sign")), + (ORDER_STATE_SIGNING, _("Signing")), (ORDER_STATE_PENDING, _("Pending")), - (ORDER_STATE_CANCELED, pgettext_lazy("As in: the order is cancelled.", "Canceled")), - ( - ORDER_STATE_VALIDATED, - pgettext_lazy("As in: the order is validated.", "Validated"), - ), + (ORDER_STATE_CANCELED, pgettext_lazy("As in: the order is canceled.", "Canceled")), ( ORDER_STATE_PENDING_PAYMENT, pgettext_lazy("As in: the order payment is pending.", "Pending payment"), @@ -93,10 +96,15 @@ pgettext_lazy("As in: the order is completed.", "Completed"), ), ) -BINDING_ORDER_STATES = ( - ORDER_STATE_SUBMITTED, +ORDER_STATE_ALLOW_ENROLLMENT = ( + ORDER_STATE_COMPLETED, + ORDER_STATE_PENDING_PAYMENT, + ORDER_STATE_FAILED_PAYMENT, +) +ORDER_STATES_BINDING = ( + *ORDER_STATE_ALLOW_ENROLLMENT, ORDER_STATE_PENDING, - ORDER_STATE_VALIDATED, + ORDER_STATE_NO_PAYMENT, ) MIN_ORDER_TOTAL_AMOUNT = 0.0 diff --git a/src/backend/joanie/core/exceptions.py b/src/backend/joanie/core/exceptions.py index 756194bfd..f48bbb90b 100644 --- a/src/backend/joanie/core/exceptions.py +++ b/src/backend/joanie/core/exceptions.py @@ -29,3 +29,9 @@ class CertificateGenerationError(Exception): Exception raised when the certificate generation process fails due to the order not meeting all specified conditions. """ + + +class InvalidConversionError(Exception): + """ + Exception raised when a conversion fails. + """ diff --git a/src/backend/joanie/core/factories.py b/src/backend/joanie/core/factories.py index 673a3126d..4654411c2 100644 --- a/src/backend/joanie/core/factories.py +++ b/src/backend/joanie/core/factories.py @@ -1,3 +1,4 @@ +# pylint: disable=too-many-lines # ruff: noqa: S311 """ Core application factories @@ -21,9 +22,18 @@ from timedelta_isoformat import timedelta as timedelta_isoformat from joanie.core import enums, models -from joanie.core.models import OrderTargetCourseRelation, ProductTargetCourseRelation +from joanie.core.models import ( + CourseState, + DocumentImage, + OrderTargetCourseRelation, + ProductTargetCourseRelation, +) from joanie.core.serializers import AddressSerializer -from joanie.core.utils import image_to_base64 +from joanie.core.utils import contract_definition, file_checksum +from joanie.core.utils.payment_schedule import ( + convert_amount_str_to_money_object, + convert_date_str_to_date_object, +) def generate_thumbnails_for_field(field, include_global=False): @@ -562,6 +572,7 @@ class ProductTargetCourseRelationFactory(factory.django.DjangoModelFactory): class Meta: model = models.ProductTargetCourseRelation skip_postgeneration_save = True + django_get_or_create = ("product", "course") product = factory.SubFactory(ProductFactory) course = factory.SubFactory(CourseFactory) @@ -615,6 +626,17 @@ def organization(self): course_relations = course_relations.filter(course=self.course) return course_relations.first().organizations.order_by("?").first() + @factory.lazy_attribute + def credit_card(self): + """Create a credit card for the order.""" + if self.product.price == 0: + return None + from joanie.payment.factories import ( # pylint: disable=import-outside-toplevel, cyclic-import + CreditCardFactory, + ) + + return CreditCardFactory(owner=self.owner) + @factory.post_generation # pylint: disable=unused-argument,no-member def target_courses(self, create, extracted, **kwargs): @@ -649,7 +671,7 @@ def main_invoice(self, create, extracted, **kwargs): extracted.save() return extracted - if self.state == enums.ORDER_STATE_VALIDATED: + if self.state == enums.ORDER_STATE_COMPLETED: # If the order is not fee and its state is validated, create # a main invoice with related transaction. from joanie.payment.factories import ( # pylint: disable=import-outside-toplevel, cyclic-import @@ -665,6 +687,275 @@ def main_invoice(self, create, extracted, **kwargs): return None + @factory.post_generation + # pylint: disable=method-hidden + def payment_schedule(self, create, extracted, **kwargs): + """ + Cast input strings for the fields `amount` and `due_date` into the appropriate types + """ + if create and extracted: + for item in extracted: + if isinstance(item["due_date"], str): + item["due_date"] = convert_date_str_to_date_object(item["due_date"]) + if isinstance(item["amount"], str): + item["amount"] = convert_amount_str_to_money_object(item["amount"]) + self.payment_schedule = extracted + return extracted + return None + + +class OrderGeneratorFactory(factory.django.DjangoModelFactory): + """A factory to create an Order""" + + class Meta: + model = models.Order + + product = factory.SubFactory(ProductFactory) + course = factory.LazyAttribute(lambda o: o.product.courses.order_by("?").first()) + total = factory.LazyAttribute(lambda o: o.product.price) + enrollment = None + state = enums.ORDER_STATE_DRAFT + + @factory.lazy_attribute + def owner(self): + """Retrieve the user from the enrollment when available or create a new one.""" + if self.enrollment: + return self.enrollment.user + return UserFactory() + + @factory.lazy_attribute + def organization(self): + """Retrieve the organization from the product/course relation.""" + if self.state == enums.ORDER_STATE_DRAFT: + return None + + course_relations = self.product.course_relations + if self.course: + course_relations = course_relations.filter(course=self.course) + return course_relations.first().organizations.order_by("?").first() + + @factory.post_generation + def main_invoice(self, create, extracted, **kwargs): + """ + Generate invoice if needed + """ + if create: + if extracted is not None: + # If a main_invoice is passed, link it to the order. + extracted.order = self + extracted.save() + return extracted + + from joanie.payment.factories import ( # pylint: disable=import-outside-toplevel, cyclic-import + InvoiceFactory, + ) + + return InvoiceFactory( + order=self, + total=self.total, + ) + return None + + @factory.post_generation + # pylint: disable=unused-argument + def contract(self, create, extracted, **kwargs): + """Create a contract for the order.""" + if extracted: + return extracted + + if self.state in [ + enums.ORDER_STATE_TO_SIGN, + enums.ORDER_STATE_SIGNING, + enums.ORDER_STATE_TO_SAVE_PAYMENT_METHOD, + enums.ORDER_STATE_PENDING, + enums.ORDER_STATE_PENDING_PAYMENT, + enums.ORDER_STATE_NO_PAYMENT, + enums.ORDER_STATE_FAILED_PAYMENT, + enums.ORDER_STATE_COMPLETED, + enums.ORDER_STATE_CANCELED, + ]: + if not self.product.contract_definition: + self.product.contract_definition = ContractDefinitionFactory() + self.product.save() + + is_signed = self.state not in [ + enums.ORDER_STATE_TO_SIGN, + enums.ORDER_STATE_SIGNING, + ] + context = kwargs.get( + "context", + contract_definition.generate_document_context( + contract_definition=self.product.contract_definition, + user=self.owner, + order=self, + ) + if is_signed + else None, + ) + student_signed_on = kwargs.get( + "student_signed_on", django_timezone.now() if is_signed else None + ) + organization_signed_on = kwargs.get( + "organization_signed_on", + django_timezone.now() if is_signed else None, + ) + submitted_for_signature_on = kwargs.get( + "submitted_for_signature_on", + django_timezone.now() + if student_signed_on and not organization_signed_on + else None, + ) + definition_checksum = kwargs.get( + "definition_checksum", + "fake_test_file_hash_1" if is_signed else None, + ) + signature_backend_reference = kwargs.get( + "signature_backend_reference", + f"wfl_fake_dummy_demo_dev_{uuid.uuid4()}" if is_signed else None, + ) + return ContractFactory( + order=self, + student_signed_on=student_signed_on, + submitted_for_signature_on=submitted_for_signature_on, + organization_signed_on=organization_signed_on, + definition=self.product.contract_definition, + context=context, + definition_checksum=definition_checksum, + signature_backend_reference=signature_backend_reference, + ) + + return None + + @factory.lazy_attribute + def credit_card(self): + """Create a credit card for the order.""" + if self.state in [ + enums.ORDER_STATE_PENDING, + enums.ORDER_STATE_PENDING_PAYMENT, + enums.ORDER_STATE_NO_PAYMENT, + enums.ORDER_STATE_FAILED_PAYMENT, + enums.ORDER_STATE_COMPLETED, + enums.ORDER_STATE_CANCELED, + ]: + from joanie.payment.factories import ( # pylint: disable=import-outside-toplevel, cyclic-import + CreditCardFactory, + ) + + return CreditCardFactory(owner=self.owner) + + return None + + @factory.post_generation + # pylint: disable=unused-argument + def target_courses(self, create, extracted, **kwargs): + """ + If the order has a state other than draft, it should have been submitted so + target courses should have been copied from the product target courses. + """ + if extracted: + self.target_courses.set(extracted) + + @factory.post_generation + # pylint: disable=unused-argument, too-many-branches + # ruff: noqa: PLR0912 + def billing_address(self, create, extracted, **kwargs): + """ + Create a billing address for the order. + This method also handles the state transitions of the order based on the target state + and whether the order is free or not. + It updates the payment schedule states accordingly. + """ + target_state = self.state + if self.state not in [ + enums.ORDER_STATE_DRAFT, + enums.ORDER_STATE_ASSIGNED, + ]: + self.state = enums.ORDER_STATE_DRAFT + + CourseRunFactory( + course=self.course, + is_gradable=True, + state=CourseState.ONGOING_OPEN, + end=django_timezone.now() + timedelta(days=200), + ) + ProductTargetCourseRelationFactory( + product=self.product, + course=self.course, + is_graded=True, + ) + + if extracted: + self.init_flow(billing_address=extracted) + else: + from joanie.payment.factories import ( # pylint: disable=import-outside-toplevel, cyclic-import + BillingAddressDictFactory, + ) + + self.init_flow(billing_address=BillingAddressDictFactory()) + + if target_state == enums.ORDER_STATE_SIGNING: + if not self.contract.submitted_for_signature_on: + self.submit_for_signature(self.owner) + else: + self.state = target_state + self.save() + + if ( + not self.is_free + and self.has_contract + and target_state + not in [ + enums.ORDER_STATE_DRAFT, + enums.ORDER_STATE_ASSIGNED, + enums.ORDER_STATE_TO_SIGN, + ] + ): + self.generate_schedule() + + if ( + target_state + in [ + enums.ORDER_STATE_PENDING_PAYMENT, + enums.ORDER_STATE_NO_PAYMENT, + enums.ORDER_STATE_FAILED_PAYMENT, + enums.ORDER_STATE_COMPLETED, + ] + and not self.is_free + ): + if target_state == enums.ORDER_STATE_PENDING_PAYMENT: + self.payment_schedule[0]["state"] = enums.PAYMENT_STATE_PAID + if target_state == enums.ORDER_STATE_NO_PAYMENT: + self.payment_schedule[0]["state"] = enums.PAYMENT_STATE_REFUSED + if target_state == enums.ORDER_STATE_FAILED_PAYMENT: + self.state = target_state + self.payment_schedule[0]["state"] = enums.PAYMENT_STATE_PAID + self.payment_schedule[1]["state"] = enums.PAYMENT_STATE_REFUSED + if target_state == enums.ORDER_STATE_COMPLETED: + self.flow.update() + for payment in self.payment_schedule: + payment["state"] = enums.PAYMENT_STATE_PAID + self.save() + self.flow.update() + + if target_state == enums.ORDER_STATE_CANCELED: + self.flow.cancel() + + @factory.post_generation + # pylint: disable=method-hidden + def payment_schedule(self, create, extracted, **kwargs): + """ + Cast input strings for the fields `amount` and `due_date` into the appropriate types + """ + if create and extracted: + for item in extracted: + if isinstance(item["due_date"], str): + item["due_date"] = convert_date_str_to_date_object(item["due_date"]) + if isinstance(item["amount"], str): + item["amount"] = convert_amount_str_to_money_object(item["amount"]) + self.payment_schedule = extracted + return extracted + return None + class OrderTargetCourseRelationFactory(factory.django.DjangoModelFactory): """A factory to create OrderTargetCourseRelation object""" @@ -759,7 +1050,7 @@ class Meta: order = factory.SubFactory( OrderFactory, - state=enums.ORDER_STATE_VALIDATED, + state=enums.ORDER_STATE_COMPLETED, product__type=enums.PRODUCT_TYPE_CREDENTIAL, product__contract_definition=factory.SubFactory(ContractDefinitionFactory), ) @@ -785,6 +1076,16 @@ def context(self): is_main=True ).first() course_dates = self.order.get_equivalent_course_run_dates() + + logo_checksum = file_checksum(self.order.organization.logo) + logo_image, created = DocumentImage.objects.get_or_create( + checksum=logo_checksum, + defaults={"file": self.order.organization.logo}, + ) + if created: + self.definition.images.set([logo_image]) + organization_logo_id = str(logo_image.id) + return { "contract": { "body": self.definition.get_body_in_html(), @@ -822,12 +1123,11 @@ def context(self): "phone_number": self.order.owner.phone_number, }, "organization": { - "logo": image_to_base64(self.order.organization.logo), + "logo_id": organization_logo_id, "name": self.order.organization.safe_translation_getter( "title", language_code=self.definition.language ), "address": AddressSerializer(organization_address).data, - "signature": image_to_base64(self.order.organization.signature), "representative": self.order.organization.representative, "representative_profession": self.order.organization.representative_profession, "enterprise_code": self.order.organization.enterprise_code, diff --git a/src/backend/joanie/core/fields/schedule.py b/src/backend/joanie/core/fields/schedule.py index 70d13d63a..1806e5cc9 100644 --- a/src/backend/joanie/core/fields/schedule.py +++ b/src/backend/joanie/core/fields/schedule.py @@ -1,5 +1,9 @@ """Utils for the order payment schedule field""" +from datetime import date +from json import JSONDecoder +from json.decoder import WHITESPACE + from django.core.serializers.json import DjangoJSONEncoder from stockholm import Money @@ -7,7 +11,7 @@ class OrderPaymentScheduleEncoder(DjangoJSONEncoder): """ - A JSON encoder for datetime objects. + A JSON encoder for order payment schedule objects. """ def default(self, o): @@ -15,3 +19,16 @@ def default(self, o): return o.amount_as_string() return super().default(o) + + +class OrderPaymentScheduleDecoder(JSONDecoder): + """ + A JSON decoder for order payment schedule objects. + """ + + def decode(self, s, _w=WHITESPACE.match): + payment_schedule = super().decode(s, _w) + for installment in payment_schedule: + installment["amount"] = Money(installment["amount"]) + installment["due_date"] = date.fromisoformat(installment["due_date"]) + return payment_schedule diff --git a/src/backend/joanie/core/flows/order.py b/src/backend/joanie/core/flows/order.py index b40aacf8f..9c8b6f64e 100644 --- a/src/backend/joanie/core/flows/order.py +++ b/src/backend/joanie/core/flows/order.py @@ -1,5 +1,8 @@ """Order flows.""" +import logging +from contextlib import suppress + from django.apps import apps from django.utils import timezone @@ -7,7 +10,14 @@ from viewflow import fsm from joanie.core import enums +from joanie.core.utils.payment_schedule import ( + has_installments_to_debit, + is_installment_to_debit, +) from joanie.payment import get_payment_backend +from joanie.payment.backends.base import BasePaymentBackend + +logger = logging.getLogger(__name__) class OrderFlow: @@ -26,137 +36,138 @@ def _set_order_state(self, value): def _get_order_state(self): return self.instance.state - def _can_be_state_submitted(self): + def _can_be_assigned(self): """ - An order can be submitted if the order has a course, an organization, - an owner, and a product + An order can be assigned if it has an organization. """ - return ( - (self.instance.course is not None or self.instance.enrollment is not None) - and self.instance.organization is not None - and self.instance.owner is not None - and self.instance.product is not None - ) + return self.instance.organization is not None - def _can_be_state_validated(self): + @state.transition( + source=enums.ORDER_STATE_DRAFT, + target=enums.ORDER_STATE_ASSIGNED, + conditions=[_can_be_assigned], + ) + def assign(self): """ - An order can be validated if the product is free or if it - has invoices. + Transition order to assigned state. """ - return ( - self.instance.total == enums.MIN_ORDER_TOTAL_AMOUNT - or self.instance.invoices.count() > 0 - ) - @state.transition( - source=[enums.ORDER_STATE_DRAFT, enums.ORDER_STATE_PENDING], - target=enums.ORDER_STATE_SUBMITTED, - conditions=[_can_be_state_submitted], - ) - def submit(self, billing_address=None, credit_card_id=None): + def _can_be_state_to_save_payment_method(self): """ - Transition order to submitted state. - Create a payment if the product is fee + An order state can be set to_save_payment_method if the order is not free + has no payment method and no contract to sign. """ - CreditCard = apps.get_model("payment", "CreditCard") # pylint: disable=invalid-name - payment_backend = get_payment_backend() - if credit_card_id: - try: - credit_card = CreditCard.objects.get_card_for_owner( - pk=credit_card_id, - username=self.instance.owner.username, - ) - return payment_backend.create_one_click_payment( - order=self.instance, - billing_address=billing_address, - credit_card_token=credit_card.token, - ) - except (CreditCard.DoesNotExist, NotImplementedError): - pass - payment_info = payment_backend.create_payment( - order=self.instance, billing_address=billing_address + return ( + not self.instance.is_free + and not self.instance.has_payment_method + and not self.instance.has_unsigned_contract ) - return payment_info - @state.transition( source=[ - enums.ORDER_STATE_DRAFT, - enums.ORDER_STATE_SUBMITTED, + enums.ORDER_STATE_ASSIGNED, + enums.ORDER_STATE_SIGNING, + enums.ORDER_STATE_PENDING, ], - target=enums.ORDER_STATE_VALIDATED, - conditions=[_can_be_state_validated], + target=enums.ORDER_STATE_TO_SAVE_PAYMENT_METHOD, + conditions=[_can_be_state_to_save_payment_method], ) - def validate(self): + def to_save_payment_method(self): """ - Transition order to validated state. + Transition order to to_save_payment_method state. + """ + + def _can_be_state_to_sign(self): """ + An order state can be set to to_sign if the order has an unsigned contract. + """ + return ( + self.instance.has_unsigned_contract + and not self.instance.has_submitted_contract + ) @state.transition( - source=fsm.State.ANY, - target=enums.ORDER_STATE_CANCELED, + source=[enums.ORDER_STATE_ASSIGNED, enums.ORDER_STATE_SIGNING], + target=enums.ORDER_STATE_TO_SIGN, + conditions=[_can_be_state_to_sign], ) - def cancel(self): + def to_sign(self): """ - Mark order instance as "canceled". + Transition order to to_sign state. + """ + + def _can_be_state_signing(self): + """ + An order state can be set to signing if + we are waiting for the signature provider to validate the student's signature. """ + return ( + self.instance.contract.submitted_for_signature_on + and not self.instance.contract.student_signed_on + ) @state.transition( - source=[enums.ORDER_STATE_SUBMITTED, enums.ORDER_STATE_VALIDATED], - target=enums.ORDER_STATE_PENDING, + source=enums.ORDER_STATE_TO_SIGN, + target=enums.ORDER_STATE_SIGNING, + conditions=[_can_be_state_signing], ) - def pending(self, payment_id=None): + def signing(self): """ - Mark order instance as "pending" and abort the related - payment if there is one + Transition order to signing state. """ - if payment_id: - payment_backend = get_payment_backend() - payment_backend.abort_payment(payment_id) - def _can_be_state_pending_payment(self): + def _can_be_state_pending(self): """ - An order state can be set to pending_payment if no installment - is refused. + An order state can be set to pending if the order is not free + and has a payment method and no contract to sign. """ - return any( - installment.get("state") not in [enums.PAYMENT_STATE_REFUSED] - for installment in self.instance.payment_schedule - ) + return ( + self.instance.is_free or self.instance.has_payment_method + ) and not self.instance.has_unsigned_contract - def _can_be_state_completed(self): + @state.transition( + source=[ + enums.ORDER_STATE_ASSIGNED, + enums.ORDER_STATE_TO_SAVE_PAYMENT_METHOD, + enums.ORDER_STATE_SIGNING, + ], + target=enums.ORDER_STATE_PENDING, + conditions=[_can_be_state_pending], + ) + def pending(self): """ - An order state can be set to completed if all installments - are completed. + Transition order to pending state. """ - return all( - installment.get("state") in [enums.PAYMENT_STATE_PAID] - for installment in self.instance.payment_schedule - ) - def _can_be_state_no_payment(self): + @state.transition( + source=fsm.State.ANY, + target=enums.ORDER_STATE_CANCELED, + ) + def cancel(self): """ - An order state can be set to no_payment if the first installment is refused. + Mark order instance as "canceled". """ - return self.instance.payment_schedule[0].get("state") in [ - enums.PAYMENT_STATE_REFUSED - ] - def _can_be_state_failed_payment(self): + def _can_be_state_completed(self): """ - An order state can be set to failed_payment if any installment except the first - is refused. + An order state can be set to completed if all installments + are completed. """ - return any( - installment.get("state") in [enums.PAYMENT_STATE_REFUSED] - for installment in self.instance.payment_schedule[1:] - ) + fully_paid = self.instance.is_free + if not fully_paid and self.instance.payment_schedule: + fully_paid = all( + installment.get("state") in [enums.PAYMENT_STATE_PAID] + for installment in self.instance.payment_schedule + ) + return fully_paid and not self.instance.has_unsigned_contract @state.transition( source=[ + enums.ORDER_STATE_ASSIGNED, enums.ORDER_STATE_PENDING_PAYMENT, enums.ORDER_STATE_FAILED_PAYMENT, enums.ORDER_STATE_PENDING, + enums.ORDER_STATE_SIGNING, ], target=enums.ORDER_STATE_COMPLETED, conditions=[_can_be_state_completed], @@ -166,6 +177,20 @@ def complete(self): Complete the order. """ + def _can_be_state_pending_payment(self): + """ + An order state can be set to pending_payment if the first installment + is paid and all others are not refused. + """ + + [first_installment_state, *other_installments_states] = [ + installment.get("state") for installment in self.instance.payment_schedule + ] + + return first_installment_state == enums.PAYMENT_STATE_PAID and not any( + state == enums.PAYMENT_STATE_REFUSED for state in other_installments_states + ) + @state.transition( source=[ enums.ORDER_STATE_PENDING_PAYMENT, @@ -181,6 +206,14 @@ def pending_payment(self): Mark order instance as "pending_payment". """ + def _can_be_state_no_payment(self): + """ + An order state can be set to no_payment if the first installment is refused. + """ + return self.instance.payment_schedule[0].get("state") in [ + enums.PAYMENT_STATE_REFUSED + ] + @state.transition( source=enums.ORDER_STATE_PENDING, target=enums.ORDER_STATE_NO_PAYMENT, @@ -191,6 +224,16 @@ def no_payment(self): Mark order instance as "no_payment". """ + def _can_be_state_failed_payment(self): + """ + An order state can be set to failed_payment if any installment except the first + is refused. + """ + return any( + installment.get("state") in [enums.PAYMENT_STATE_REFUSED] + for installment in self.instance.payment_schedule[1:] + ) + @state.transition( source=enums.ORDER_STATE_PENDING_PAYMENT, target=enums.ORDER_STATE_FAILED_PAYMENT, @@ -201,25 +244,111 @@ def failed_payment(self): Mark order instance as "failed_payment". """ + def update(self): + """ + Update the order state. + """ + logger.debug("Transitioning order %s", self.instance.id) + for transition in [ + self.complete, + self.to_sign, + self.signing, + self.to_save_payment_method, + self.pending, + self.pending_payment, + self.no_payment, + self.failed_payment, + ]: + with suppress(fsm.TransitionNotAllowed): + logger.debug( + " %s -> %s", + self.instance.state, + transition.label, + ) + transition() + logger.debug(" Done") + return + @state.on_success() - def _post_transition_success(self, descriptor, source, target): # pylint: disable=unused-argument + def _post_transition_success(self, descriptor, source, target, **kwargs): # pylint: disable=unused-argument """Post transition actions""" self.instance.save() + # When an order's subscription is confirmed, we send an email to the user about the + # confirmation + if ( + source + in [enums.ORDER_STATE_TO_SAVE_PAYMENT_METHOD, enums.ORDER_STATE_SIGNING] + and target == enums.ORDER_STATE_PENDING + ): + # pylint: disable=protected-access + # ruff : noqa : SLF001 + BasePaymentBackend._send_mail_subscription_success(order=self.instance) - # When an order is validated, if the user was previously enrolled for free in any of the + if ( + not self.instance.payment_schedule + and not self.instance.is_free + and target in [enums.ORDER_STATE_PENDING, enums.ORDER_STATE_COMPLETED] + ): + self.instance.generate_schedule() + + # When we generate the payment schedule and if the course has already started, + # the 1st installment due date of the order's payment schedule will be set to the current + # day. Since we only debit the next night through a cronjob, we need be able to make the + # user pay to have access to his course, and avoid that the has to wait the next + # day to start it. + if ( + source == enums.ORDER_STATE_TO_SAVE_PAYMENT_METHOD + and target == enums.ORDER_STATE_PENDING + and has_installments_to_debit(self.instance) + and self.instance.credit_card + and self.instance.credit_card.token + ): + installment = next( + ( + installment + for installment in self.instance.payment_schedule + if is_installment_to_debit(installment) + ), + ) + payment_backend = get_payment_backend() + payment_backend.create_zero_click_payment( + order=self.instance, + credit_card_token=self.instance.credit_card.token, + installment=installment, + ) + + # 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". - if target in [enums.ORDER_STATE_VALIDATED, enums.ORDER_STATE_CANCELED]: + if ( + source + in [ + enums.ORDER_STATE_ASSIGNED, + enums.ORDER_STATE_PENDING, + enums.ORDER_STATE_NO_PAYMENT, + ] + and target + in [enums.ORDER_STATE_PENDING_PAYMENT, enums.ORDER_STATE_COMPLETED] + ) or target == enums.ORDER_STATE_CANCELED: for enrollment in self.instance.get_target_enrollments( is_active=True ).select_related("course_run", "user"): enrollment.set() - # Only enroll user if the product has no contract to sign, otherwise we should wait - # for the contract to be signed before enrolling the user. + # Enroll user if the order is assigned, pending or no payment and the target is + # completed or pending payment. + # assign -> completed : free product without contract + # pending -> pending_payment : first installment paid + # no_payment -> pending_payment : first installment paid + # pending -> completed : fully paid order + # no_payment -> completed : fully paid order if ( - target == enums.ORDER_STATE_VALIDATED - and self.instance.product.contract_definition is None + source == enums.ORDER_STATE_ASSIGNED + and target == enums.ORDER_STATE_COMPLETED + ) or ( + source in [enums.ORDER_STATE_PENDING, enums.ORDER_STATE_NO_PAYMENT] + and target + in [enums.ORDER_STATE_PENDING_PAYMENT, enums.ORDER_STATE_COMPLETED] ): try: # ruff : noqa : BLE001 @@ -231,12 +360,6 @@ def _post_transition_success(self, descriptor, source, target): # pylint: disab if target == enums.ORDER_STATE_CANCELED: self.instance.unenroll_user_from_course_runs() - if order_enrollment := self.instance.enrollment: - # Trigger LMS synchronization for source enrollment to update mode - # Make sure it is saved in case the state is modified e.g in case of synchronization - # failure - order_enrollment.set() - # Reset course product relation cache if its representation is impacted by changes # on related orders # e.g. number of remaining seats when an order group is used diff --git a/src/backend/joanie/core/helpers.py b/src/backend/joanie/core/helpers.py index af0f9dabf..394407535 100644 --- a/src/backend/joanie/core/helpers.py +++ b/src/backend/joanie/core/helpers.py @@ -23,7 +23,7 @@ def generate_certificates_for_orders(orders): orders_filtered = ( orders_queryset.filter( - state=enums.ORDER_STATE_VALIDATED, + state=enums.ORDER_STATE_COMPLETED, certificate__isnull=True, product__type__in=enums.PRODUCT_TYPE_CERTIFICATE_ALLOWED, ) diff --git a/src/backend/joanie/core/management/commands/process_payment_schedules.py b/src/backend/joanie/core/management/commands/process_payment_schedules.py index d2f303aa0..201d2b4dc 100644 --- a/src/backend/joanie/core/management/commands/process_payment_schedules.py +++ b/src/backend/joanie/core/management/commands/process_payment_schedules.py @@ -5,7 +5,8 @@ from django.core.management import BaseCommand from joanie.core.models import Order -from joanie.core.tasks.payment_schedule import process_today_installment +from joanie.core.tasks.payment_schedule import debit_pending_installment +from joanie.core.utils.payment_schedule import has_installments_to_debit logger = logging.getLogger(__name__) @@ -22,12 +23,12 @@ def handle(self, *args, **options): Retrieve all pending payment schedules and process them. """ logger.info("Starting processing of all pending payment schedules.") - found_orders = Order.objects.find_today_installments() - if not found_orders: - logger.info("No pending payment schedule found.") - return - - logger.info("Found %s pending payment schedules.", len(found_orders)) - for order in found_orders: - logger.info("Processing payment schedule for order %s.", order.id) - process_today_installment.delay(order.id) + found_orders_count = 0 + + for order in Order.objects.find_pending_installments().iterator(): + if has_installments_to_debit(order): + logger.info("Processing payment schedule for order %s.", order.id) + debit_pending_installment.delay(order.id) + found_orders_count += 1 + + logger.info("Found %s pending payment schedules.", found_orders_count) diff --git a/src/backend/joanie/core/management/commands/send_mail_upcoming_debit.py b/src/backend/joanie/core/management/commands/send_mail_upcoming_debit.py new file mode 100644 index 000000000..4138df9a8 --- /dev/null +++ b/src/backend/joanie/core/management/commands/send_mail_upcoming_debit.py @@ -0,0 +1,52 @@ +"""Management command to send a reminder email to the order's owner on next installment to pay""" + +import logging +from datetime import timedelta + +from django.conf import settings +from django.core.management import BaseCommand +from django.utils import timezone + +from joanie.core.models import Order +from joanie.core.tasks.payment_schedule import send_mail_reminder_installment_debit_task +from joanie.core.utils.payment_schedule import is_next_installment_to_debit + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Command to send an email to the order's owner notifying them that an upcoming + installment debit from their payment schedule will be debited soon on their credit card. + """ + + help = __doc__ + + def handle(self, *args, **options): + """ + Retrieve all upcoming pending payment schedules depending on the target due date and + send an email reminder to the order's owner who will be soon debited. + """ + logger.info( + "Starting processing order payment schedule for upcoming installments." + ) + due_date = timezone.localdate() + timedelta( + days=settings.JOANIE_INSTALLMENT_REMINDER_PERIOD_DAYS + ) + + found_orders_count = 0 + for order in Order.objects.find_pending_installments().iterator(): + for installment in order.payment_schedule: + if is_next_installment_to_debit( + installment=installment, due_date=due_date + ): + logger.info("Sending reminder mail for order %s.", order.id) + send_mail_reminder_installment_debit_task.delay( + order_id=order.id, installment_id=installment["id"] + ) + found_orders_count += 1 + + logger.info( + "Found %s upcoming 'pending' installment to debit", + found_orders_count, + ) diff --git a/src/backend/joanie/core/migrations/0034_alter_order_state.py b/src/backend/joanie/core/migrations/0034_alter_order_state.py new file mode 100644 index 000000000..2ccc26afe --- /dev/null +++ b/src/backend/joanie/core/migrations/0034_alter_order_state.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.13 on 2024-05-16 11:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0033_alter_order_state'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='state', + field=models.CharField(choices=[('draft', 'Draft'), ('assigned', 'Assigned'), ('to_save_payment_method', 'To save payment method'), ('to_sign', 'To sign'), ('to_sign_and_to_save_payment_method', 'To sign and to save payment method'), ('submitted', 'Submitted'), ('pending', 'Pending'), ('canceled', 'Canceled'), ('validated', 'Validated'), ('pending_payment', 'Pending payment'), ('failed_payment', 'Failed payment'), ('no_payment', 'No payment'), ('completed', 'Completed')], db_index=True, default='draft'), + ), + ] diff --git a/src/backend/joanie/core/migrations/0035_order_credit_card.py b/src/backend/joanie/core/migrations/0035_order_credit_card.py new file mode 100644 index 000000000..002f0657d --- /dev/null +++ b/src/backend/joanie/core/migrations/0035_order_credit_card.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.13 on 2024-05-23 10:52 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('payment', '0008_creditcard_initial_issuer_transaction_identifier'), + ('core', '0034_alter_order_state'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='credit_card', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='payment.creditcard', verbose_name='credit card'), + ), + ] diff --git a/src/backend/joanie/core/migrations/0036_order_state_migration.py b/src/backend/joanie/core/migrations/0036_order_state_migration.py new file mode 100644 index 000000000..2adef170b --- /dev/null +++ b/src/backend/joanie/core/migrations/0036_order_state_migration.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.13 on 2024-05-28 09:28 + +from django.db import migrations + + +def migrate_order_states(apps, schema_editor): + Order = apps.get_model("core", "Order") + Order.objects.filter(state="validated" ).update(state="completed") + Order.objects.filter(state__in=["pending", "submitted"]).update(state="canceled") + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0035_order_credit_card"), + ] + + operations = [ + migrations.RunPython(migrate_order_states, migrations.RunPython.noop), + ] diff --git a/src/backend/joanie/core/migrations/0037_alter_order_state.py b/src/backend/joanie/core/migrations/0037_alter_order_state.py new file mode 100644 index 000000000..778fc463c --- /dev/null +++ b/src/backend/joanie/core/migrations/0037_alter_order_state.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.13 on 2024-05-28 13:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0036_order_state_migration'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='state', + field=models.CharField(choices=[('draft', 'Draft'), ('assigned', 'Assigned'), ('to_save_payment_method', 'To save payment method'), ('to_sign', 'To sign'), ('to_sign_and_to_save_payment_method', 'To sign and to save payment method'), ('pending', 'Pending'), ('canceled', 'Canceled'), ('pending_payment', 'Pending payment'), ('failed_payment', 'Failed payment'), ('no_payment', 'No payment'), ('completed', 'Completed')], db_index=True, default='draft'), + ), + ] diff --git a/src/backend/joanie/core/migrations/0038_alter_order_state.py b/src/backend/joanie/core/migrations/0038_alter_order_state.py new file mode 100644 index 000000000..9ef00d570 --- /dev/null +++ b/src/backend/joanie/core/migrations/0038_alter_order_state.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.13 on 2024-06-03 15:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0037_alter_order_state'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='state', + field=models.CharField(choices=[('draft', 'Draft'), ('assigned', 'Assigned'), ('to_save_payment_method', 'To save payment method'), ('to_sign', 'To sign'), ('pending', 'Pending'), ('canceled', 'Canceled'), ('pending_payment', 'Pending payment'), ('failed_payment', 'Failed payment'), ('no_payment', 'No payment'), ('completed', 'Completed')], db_index=True, default='draft'), + ), + ] diff --git a/src/backend/joanie/core/migrations/0039_alter_order_has_consent_to_terms_and_more.py b/src/backend/joanie/core/migrations/0039_alter_order_has_consent_to_terms_and_more.py new file mode 100644 index 000000000..ea3bfc551 --- /dev/null +++ b/src/backend/joanie/core/migrations/0039_alter_order_has_consent_to_terms_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.13 on 2024-06-10 16:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0038_alter_order_state'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='has_consent_to_terms', + field=models.BooleanField(db_column='has_consent_to_terms', default=False, editable=False, help_text='User has consented to the platform terms and conditions.', verbose_name='has consent to terms'), + ), + migrations.RenameField( + model_name='order', + old_name='has_consent_to_terms', + new_name='_has_consent_to_terms', + ), + ] diff --git a/src/backend/joanie/core/migrations/0040_alter_order_payment_schedule.py b/src/backend/joanie/core/migrations/0040_alter_order_payment_schedule.py new file mode 100644 index 000000000..e3e1996a7 --- /dev/null +++ b/src/backend/joanie/core/migrations/0040_alter_order_payment_schedule.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.13 on 2024-07-02 11:10 + +from django.db import migrations, models +import joanie.core.fields.schedule + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0039_alter_order_has_consent_to_terms_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='payment_schedule', + field=models.JSONField(blank=True, decoder=joanie.core.fields.schedule.OrderPaymentScheduleDecoder, editable=False, encoder=joanie.core.fields.schedule.OrderPaymentScheduleEncoder, help_text='Payment schedule for the order.', null=True, verbose_name='payment schedule'), + ), + ] diff --git a/src/backend/joanie/core/migrations/0041_contractdefinition_images.py b/src/backend/joanie/core/migrations/0041_contractdefinition_images.py new file mode 100644 index 000000000..5334b0c49 --- /dev/null +++ b/src/backend/joanie/core/migrations/0041_contractdefinition_images.py @@ -0,0 +1,69 @@ +# Generated by Django 4.2.13 on 2024-07-03 08:53 + +from django.db import migrations, models + +from joanie.core.utils import file_checksum + + +def update_context(apps, contract): + """Generate new context for a contract.""" + DocumentImage = apps.get_model("core", "DocumentImage") + + if ( + not contract.context + or not contract.context.get("organization") + or not contract.context.get("organization").get("logo") + ): + return + + logo = contract.order.organization.logo + logo_checksum = file_checksum(logo) + logo_image, _ = DocumentImage.objects.get_or_create( + checksum=logo_checksum, defaults={"file": logo} + ) + contract.definition.images.set([logo_image]) + + contract.context["organization"]["logo_id"] = str(logo_image.id) + del contract.context["organization"]["logo"] + + +def migrate_contract_contexts(apps, schema_editor): + """ + Upgrade all contracts contexts. This migration is in charge of + creating all the DocumentImage instances needed for the contract, set relation + between contract and those images then update context for each contract. + """ + Contract = apps.get_model("core", "Contract") + # Only update contracts that are not fully signed. + contracts = Contract.objects.all() + for contract in contracts: + if ( + contract.organization_signed_on is not None + and contract.student_signed_on is not None + and not contract.submitted_for_signature_on + ): + contract.context = None + else: + update_context(apps, contract) + Contract.objects.bulk_update(contracts, ["context"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0040_alter_order_payment_schedule"), + ] + + operations = [ + migrations.AddField( + model_name="contractdefinition", + name="images", + field=models.ManyToManyField( + blank=True, + editable=False, + related_name="contract_definitions", + to="core.documentimage", + verbose_name="images", + ), + ), + migrations.RunPython(migrate_contract_contexts, migrations.RunPython.noop), + ] diff --git a/src/backend/joanie/core/migrations/0042_alter_order_state.py b/src/backend/joanie/core/migrations/0042_alter_order_state.py new file mode 100644 index 000000000..a832dbe88 --- /dev/null +++ b/src/backend/joanie/core/migrations/0042_alter_order_state.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.13 on 2024-07-03 16:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0041_contractdefinition_images'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='state', + field=models.CharField(choices=[('draft', 'Draft'), ('assigned', 'Assigned'), ('to_save_payment_method', 'To save payment method'), ('to_sign', 'To sign'), ('signing', 'Signing'), ('pending', 'Pending'), ('canceled', 'Canceled'), ('pending_payment', 'Pending payment'), ('failed_payment', 'Failed payment'), ('no_payment', 'No payment'), ('completed', 'Completed')], db_index=True, default='draft'), + ), + ] diff --git a/src/backend/joanie/core/migrations/0043_address_unique_address_per_user_and_more.py b/src/backend/joanie/core/migrations/0043_address_unique_address_per_user_and_more.py new file mode 100644 index 000000000..b1638f340 --- /dev/null +++ b/src/backend/joanie/core/migrations/0043_address_unique_address_per_user_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.15 on 2024-08-12 17:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0042_alter_order_state'), + ] + + operations = [ + migrations.AddConstraint( + model_name='address', + constraint=models.UniqueConstraint(fields=('owner', 'address', 'postcode', 'city', 'country', 'first_name', 'last_name'), name='unique_address_per_user'), + ), + migrations.AddConstraint( + model_name='address', + constraint=models.UniqueConstraint(fields=('organization', 'address', 'postcode', 'city', 'country', 'first_name', 'last_name'), name='unique_address_per_organization'), + ), + ] diff --git a/src/backend/joanie/core/models/accounts.py b/src/backend/joanie/core/models/accounts.py index b8d30f6f5..127723cb4 100644 --- a/src/backend/joanie/core/models/accounts.py +++ b/src/backend/joanie/core/models/accounts.py @@ -206,6 +206,30 @@ class Meta: name="main_address_must_be_reusable", violation_error_message=_("Main address must be reusable."), ), + models.UniqueConstraint( + fields=[ + "owner", + "address", + "postcode", + "city", + "country", + "first_name", + "last_name", + ], + name="unique_address_per_user", + ), + models.UniqueConstraint( + fields=[ + "organization", + "address", + "postcode", + "city", + "country", + "first_name", + "last_name", + ], + name="unique_address_per_organization", + ), ] def __str__(self): diff --git a/src/backend/joanie/core/models/contracts.py b/src/backend/joanie/core/models/contracts.py index 1c10666e0..e08f4a10f 100644 --- a/src/backend/joanie/core/models/contracts.py +++ b/src/backend/joanie/core/models/contracts.py @@ -16,7 +16,7 @@ import markdown from joanie.core import enums -from joanie.core.models.base import BaseModel +from joanie.core.models.base import BaseModel, DocumentImage logger = logging.getLogger(__name__) @@ -35,13 +35,19 @@ class ContractDefinition(BaseModel): verbose_name=_("language"), help_text=_("Language of the contract definition"), ) - name = models.CharField( _("template name"), max_length=255, choices=enums.CONTRACT_NAME_CHOICES, default=enums.CONTRACT_DEFINITION, ) + images = models.ManyToManyField( + to=DocumentImage, + verbose_name=_("images"), + related_name="contract_definitions", + editable=False, + blank=True, + ) class Meta: db_table = "joanie_contract_definition" @@ -226,6 +232,7 @@ def tag_submission_for_signature(self, reference, checksum, context): self.definition_checksum = checksum self.signature_backend_reference = reference self.save() + self.order.flow.update() def reset_submission_for_signature(self): """ @@ -237,6 +244,7 @@ def reset_submission_for_signature(self): self.definition_checksum = None self.signature_backend_reference = None self.save() + self.order.flow.update() def is_eligible_for_signing(self): """ diff --git a/src/backend/joanie/core/models/courses.py b/src/backend/joanie/core/models/courses.py index 0601a17aa..d70603677 100644 --- a/src/backend/joanie/core/models/courses.py +++ b/src/backend/joanie/core/models/courses.py @@ -323,8 +323,9 @@ def signature_backend_references_to_sign(self, **kwargs): submitted_for_signature_on__isnull=False, student_signed_on__isnull=False, order__organization=self, - order__state=enums.ORDER_STATE_VALIDATED, - ).values_list("id", "signature_backend_reference") + ) + .exclude(order__state=enums.ORDER_STATE_CANCELED) + .values_list("id", "signature_backend_reference") ) if contract_ids and len(contracts_to_sign) != len(contract_ids): @@ -534,6 +535,24 @@ def save(self, *args, **kwargs): self.full_clean() super().save(*args, **kwargs) + def get_equivalent_course_run_dates(self): + """ + Return a dict of dates equivalent to course run dates + by aggregating dates of all target course runs as follows: + - start: Pick the earliest start date + - end: Pick the latest end date + - enrollment_start: Pick the latest enrollment start date + - enrollment_end: Pick the earliest enrollment end date + """ + aggregate = self.course_runs.aggregate( + models.Min("start"), + models.Max("end"), + models.Max("enrollment_start"), + models.Min("enrollment_end"), + ) + + return {key.split("__")[0]: value for key, value in aggregate.items()} + def get_selling_organizations(self, product=None): """ Return the list of organizations selling a product for the course. @@ -1138,7 +1157,7 @@ def clean(self): product__contract_definition__isnull=True, ) ), - state=enums.ORDER_STATE_VALIDATED, + state__in=enums.ORDER_STATE_ALLOW_ENROLLMENT, ) if validated_user_orders.count() == 0: message = _( diff --git a/src/backend/joanie/core/models/products.py b/src/backend/joanie/core/models/products.py index 93631b30c..e2b593df4 100644 --- a/src/backend/joanie/core/models/products.py +++ b/src/backend/joanie/core/models/products.py @@ -6,6 +6,7 @@ import logging from collections import defaultdict +from django.apps import apps from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError from django.core.validators import MinValueValidator from django.db import models @@ -15,13 +16,17 @@ import requests from parler import models as parler_models +from stockholm import Money from urllib3.util import Retry from joanie.core import enums from joanie.core.exceptions import CertificateGenerationError -from joanie.core.fields.schedule import OrderPaymentScheduleEncoder +from joanie.core.fields.schedule import ( + OrderPaymentScheduleDecoder, + OrderPaymentScheduleEncoder, +) from joanie.core.flows.order import OrderFlow -from joanie.core.models.accounts import User +from joanie.core.models.accounts import Address, User from joanie.core.models.activity_logs import ActivityLog from joanie.core.models.base import BaseModel from joanie.core.models.certifications import Certificate @@ -36,6 +41,7 @@ ) from joanie.core.utils import contract_definition as contract_definition_utility from joanie.core.utils import issuers, webhooks +from joanie.core.utils.contract_definition import embed_images_in_context from joanie.core.utils.payment_schedule import generate as generate_payment_schedule from joanie.signature.backends import get_signature_backend @@ -355,7 +361,7 @@ def get_nb_binding_orders(self): models.Q(course_id=course_id) | models.Q(enrollment__course_run__course_id=course_id), product_id=product_id, - state__in=enums.BINDING_ORDER_STATES, + state__in=enums.ORDER_STATES_BINDING, ).count() @property @@ -375,9 +381,8 @@ def find_installments(self, due_date): .filter(payment_schedule__contains=[{"due_date": due_date.isoformat()}]) ) - def find_today_installments(self): - """Retrieve orders with a payment due today.""" - due_date = timezone.now().date().isoformat() + def find_pending_installments(self): + """Retrieve orders with at least one pending installment.""" return ( super() .get_queryset() @@ -386,9 +391,7 @@ def find_today_installments(self): enums.ORDER_STATE_PENDING, enums.ORDER_STATE_PENDING_PAYMENT, ], - payment_schedule__contains=[ - {"due_date": due_date, "state": enums.PAYMENT_STATE_PENDING} - ], + payment_schedule__contains=[{"state": enums.PAYMENT_STATE_PENDING}], ) ) @@ -465,11 +468,12 @@ class Order(BaseModel): on_delete=models.RESTRICT, db_index=True, ) - has_consent_to_terms = models.BooleanField( + _has_consent_to_terms = models.BooleanField( verbose_name=_("has consent to terms"), editable=False, default=False, help_text=_("User has consented to the platform terms and conditions."), + db_column="has_consent_to_terms", ) state = models.CharField( default=enums.ORDER_STATE_DRAFT, @@ -483,6 +487,15 @@ class Order(BaseModel): blank=True, null=True, encoder=OrderPaymentScheduleEncoder, + decoder=OrderPaymentScheduleDecoder, + ) + credit_card = models.ForeignKey( + to="payment.CreditCard", + verbose_name=_("credit card"), + related_name="orders", + on_delete=models.SET_NULL, + blank=True, + null=True, ) class Meta: @@ -527,31 +540,6 @@ def __init__(self, *args, **kwargs): def __str__(self): return f"Order {self.product} for user {self.owner}" - def submit(self, billing_address=None, credit_card_id=None): - """ - Transition order to submitted state and to validate if order is free - """ - if self.total != enums.MIN_ORDER_TOTAL_AMOUNT and billing_address is None: - raise ValidationError({"billing_address": ["This field is required."]}) - - if self.state == enums.ORDER_STATE_DRAFT: - for relation in ProductTargetCourseRelation.objects.filter( - product=self.product - ): - order_relation = OrderTargetCourseRelation.objects.create( - order=self, - course=relation.course, - position=relation.position, - is_graded=relation.is_graded, - ) - order_relation.course_runs.set(relation.course_runs.all()) - - if self.total == enums.MIN_ORDER_TOTAL_AMOUNT: - self.flow.validate() - return None - - return self.flow.submit(billing_address, credit_card_id) - @property def target_course_runs(self): """ @@ -567,6 +555,9 @@ def target_course_runs(self): courses on which a list of eligible course runs was specified on the product/course relation. """ + if self.enrollment: + return CourseRun.objects.filter(enrollments=self.enrollment) + course_relations_with_course_runs = self.course_relations.filter( course_runs__isnull=False ).only("pk") @@ -591,6 +582,54 @@ def main_invoice(self) -> dict | None: except ObjectDoesNotExist: return None + @property + def is_free(self): + """ + Return True if the order is free. + """ + return not self.total + + @property + def has_payment_method(self): + """ + Return True if the order has a payment method. + """ + return ( + self.credit_card is not None + and self.credit_card.initial_issuer_transaction_identifier is not None + ) + + @property + def has_contract(self): + """ + Return True if the order has a contract. + """ + try: + return self.contract is not None # pylint: disable=no-member + except Contract.DoesNotExist: + return False + + @property + def has_submitted_contract(self): + """ + Return True if the order has a submitted contract. + Which means a contract in the process of being signed + """ + try: + return self.contract.submitted_for_signature_on is not None # pylint: disable=no-member + except Contract.DoesNotExist: + return False + + @property + def has_unsigned_contract(self): + """ + Return True if the order has an unsigned contract. + """ + try: + return self.contract.student_signed_on is None # pylint: disable=no-member + except Contract.DoesNotExist: + return self.product.contract_definition is not None + # pylint: disable=too-many-branches # ruff: noqa: PLR0912 def clean(self): @@ -703,15 +742,33 @@ def get_target_enrollments(self, is_active=None): """ Retrieve owner's enrollments related to the ordered target courses. """ - filters = { - "course_run__in": self.target_course_runs, - "user": self.owner, - } + if self.enrollment: + filters = {"pk": self.enrollment_id} + else: + filters = { + "course_run__in": self.target_course_runs, + "user": self.owner, + } if is_active is not None: filters.update({"is_active": is_active}) return Enrollment.objects.filter(**filters) + def freeze_target_courses(self): + """ + Freeze target courses of the order. + """ + for relation in ProductTargetCourseRelation.objects.filter( + product=self.product + ): + order_relation = OrderTargetCourseRelation.objects.create( + order=self, + course=relation.course, + position=relation.position, + is_graded=relation.is_graded, + ) + order_relation.course_runs.set(relation.course_runs.all()) + def enroll_user_to_course_run(self): """ Enroll user to course runs that are the unique course run opened @@ -928,19 +985,14 @@ def submit_for_signature(self, user: User): ) raise ValidationError(message) - if self.state != enums.ORDER_STATE_VALIDATED: - message = "Cannot submit an order that is not yet validated." + if self.state not in [enums.ORDER_STATE_TO_SIGN, enums.ORDER_STATE_SIGNING]: + message = "Cannot submit an order that is not to sign." logger.error(message, extra={"context": {"order": self.to_dict()}}) raise ValidationError(message) contract_definition = self.product.contract_definition - try: - contract = self.contract - except Contract.DoesNotExist: - contract = Contract(order=self, definition=contract_definition) - - if self.contract and self.contract.student_signed_on: + if self.contract.student_signed_on: message = "Contract is already signed by the student, cannot resubmit." logger.error( message, extra={"context": {"contract": self.contract.to_dict()}} @@ -951,22 +1003,25 @@ def submit_for_signature(self, user: User): context = contract_definition_utility.generate_document_context( contract_definition=contract_definition, user=user, - order=contract.order, + order=self.contract.order, ) + context_with_images = embed_images_in_context(context) file_bytes = issuers.generate_document( - name=contract_definition.name, context=context + name=contract_definition.name, context=context_with_images ) was_already_submitted = ( - contract.submitted_for_signature_on and contract.signature_backend_reference + self.contract.submitted_for_signature_on + and self.contract.signature_backend_reference ) should_be_resubmitted = was_already_submitted and ( - not contract.is_eligible_for_signing() or contract.context != context + not self.contract.is_eligible_for_signing() + or self.contract.context != context ) if should_be_resubmitted: backend_signature.delete_signing_procedure( - contract.signature_backend_reference + self.contract.signature_backend_reference ) # We want to submit or re-submit the contract for signature in three cases: @@ -986,10 +1041,10 @@ def submit_for_signature(self, user: User): file_bytes=file_bytes, order=self, ) - contract.tag_submission_for_signature(reference, checksum, context) + self.contract.tag_submission_for_signature(reference, checksum, context) return backend_signature.get_signature_invitation_link( - user.email, [contract.signature_backend_reference] + user.email, [self.contract.signature_backend_reference] ) def get_equivalent_course_run_dates(self): @@ -1016,12 +1071,14 @@ def get_equivalent_course_run_dates(self): def _get_schedule_dates(self): """ Return the schedule dates for the order. - The schedules date are based on the time the schedule is generated (right now) and the - start and the end of the course run. + The schedules date are based on contract sign date or the time the schedule is generated + (right now) and the start and the end of the course run. """ + error_message = None course_run_dates = self.get_equivalent_course_run_dates() start_date = course_run_dates["start"] end_date = course_run_dates["end"] + if not end_date or not start_date: error_message = "Cannot retrieve start or end date for order" logger.error( @@ -1029,7 +1086,13 @@ def _get_schedule_dates(self): extra={"context": {"order": self.to_dict()}}, ) raise ValidationError(error_message) - return timezone.now(), start_date, end_date + + if self.has_contract and not self.has_unsigned_contract: + signing_date = self.contract.student_signed_on + else: + signing_date = timezone.now() + + return signing_date, start_date, end_date def generate_schedule(self): """ @@ -1054,13 +1117,12 @@ def _set_installment_state(self, installment_id, state): Returns a set of boolean values to indicate if the installment is the first one, and if it is the last one. """ - first_installment_found = True for installment in self.payment_schedule: if installment["id"] == installment_id: installment["state"] = state self.save(update_fields=["payment_schedule"]) - return first_installment_found, installment == self.payment_schedule[-1] - first_installment_found = False + self.flow.update() + return raise ValueError(f"Installment with id {installment_id} not found") @@ -1069,27 +1131,14 @@ def set_installment_paid(self, installment_id): Set the state of an installment to paid in the payment schedule. """ ActivityLog.create_payment_succeeded_activity_log(self) - _, is_last = self._set_installment_state( - installment_id, enums.PAYMENT_STATE_PAID - ) - if is_last: - self.flow.complete() - else: - self.flow.pending_payment() + self._set_installment_state(installment_id, enums.PAYMENT_STATE_PAID) def set_installment_refused(self, installment_id): """ Set the state of an installment to refused in the payment schedule. """ ActivityLog.create_payment_failed_activity_log(self) - is_first, _ = self._set_installment_state( - installment_id, enums.PAYMENT_STATE_REFUSED - ) - - if is_first: - self.flow.no_payment() - else: - self.flow.failed_payment() + self._set_installment_state(installment_id, enums.PAYMENT_STATE_REFUSED) def get_first_installment_refused(self): """ @@ -1112,13 +1161,97 @@ def withdraw(self): raise ValidationError("No payment schedule found for this order") # check if current date is greater than the first installment due date - if timezone.now().isoformat() >= self.payment_schedule[0]["due_date"]: + if timezone.now().date() >= self.payment_schedule[0]["due_date"]: raise ValidationError( "Cannot withdraw order after the first installment due date" ) self.flow.cancel() + @property + def has_consent_to_terms(self): + """Redefine `has_consent_to_terms` property to raise an exception if used""" + raise DeprecationWarning( + "Access denied to has_consent_to_terms: deprecated field" + ) + + def _get_address(self, billing_address): + """ + Returns an Address instance for a billing address. + """ + if not billing_address: + raise ValidationError("Billing address is required for non-free orders.") + + address, _ = Address.objects.get_or_create( + **billing_address, + defaults={ + "owner": self.owner, + "is_reusable": False, + "title": f"Billing address of order {self.id}", + }, + ) + return address + + def _create_main_invoice(self, billing_address): + """ + Create the main invoice for the order. + """ + address = self._get_address(billing_address) + Invoice = apps.get_model("payment", "Invoice") # pylint: disable=invalid-name + Invoice.objects.get_or_create( + order=self, + defaults={"total": self.total, "recipient_address": address}, + ) + + def init_flow(self, billing_address=None): + """ + Transition order to assigned state, creates an invoice if needed and call the flow update. + """ + self.flow.assign() + if not self.is_free: + self._create_main_invoice(billing_address) + + self.freeze_target_courses() + + if self.product.contract_definition and not self.has_contract: + Contract.objects.create( + order=self, definition=self.product.contract_definition + ) + + self.flow.update() + + def get_date_next_installment_to_pay(self): + """Get the next due date of installment to pay in the payment schedule.""" + return next( + ( + installment["due_date"] + for installment in self.payment_schedule + if installment["state"] == enums.PAYMENT_STATE_PENDING + ), + None, + ) + + def get_installment_index(self, state, find_first=False): + """ + Retrieve the index of the first or last occurrence of an installment in the + payment schedule based on the input parameter payment state. + """ + position = None + for index, entry in enumerate(self.payment_schedule, start=0): + if entry["state"] == state: + position = index + if find_first: + break + return position + + def get_remaining_balance_to_pay(self): + """Get the amount of installments remaining to pay in the payment schedule.""" + return Money.sum( + installment["amount"] + for installment in self.payment_schedule + if installment["state"] == enums.PAYMENT_STATE_PENDING + ) + class OrderTargetCourseRelation(BaseModel): """ diff --git a/src/backend/joanie/core/models/site.py b/src/backend/joanie/core/models/site.py index 33b37bc14..ee8288882 100644 --- a/src/backend/joanie/core/models/site.py +++ b/src/backend/joanie/core/models/site.py @@ -1,12 +1,9 @@ """Site extension models for the Joanie project.""" -import textwrap - from django.contrib.sites.models import Site from django.db import models from django.utils.translation import gettext_lazy as _ -import markdown from parler import models as parler_models from joanie.core.models.base import BaseModel @@ -40,11 +37,6 @@ def __str__(self): def get_terms_and_conditions_in_html(self, language=None): """Return the terms and conditions in html format.""" - content = self.safe_translation_getter( - "terms_and_conditions", - language_code=language, - any_language=True, - default="", + raise DeprecationWarning( + "Terms and conditions are managed through contract definition body." ) - - return markdown.markdown(textwrap.dedent(content)) diff --git a/src/backend/joanie/core/serializers/admin.py b/src/backend/joanie/core/serializers/admin.py index df0ddc90a..f3b87934f 100755 --- a/src/backend/joanie/core/serializers/admin.py +++ b/src/backend/joanie/core/serializers/admin.py @@ -10,7 +10,7 @@ from rest_framework import serializers from rest_framework.generics import get_object_or_404 -from joanie.core import models +from joanie.core import enums, models from joanie.core.serializers.fields import ( ImageDetailField, ISO8601DurationField, @@ -1051,6 +1051,63 @@ class Meta(BaseAdminInvoiceSerializer.Meta): read_only_fields = fields +class AdminOrderPaymentSerializer(serializers.Serializer): + """ + Serializer for the order payment + """ + + id = serializers.UUIDField(required=True) + amount = serializers.DecimalField( + coerce_to_string=False, + decimal_places=2, + max_digits=9, + min_value=D(0.00), + required=True, + ) + currency = serializers.SerializerMethodField(read_only=True) + due_date = serializers.DateField(required=True) + state = serializers.ChoiceField( + choices=enums.PAYMENT_STATE_CHOICES, + required=True, + ) + + def to_internal_value(self, data): + """Used to format the amount and the due_date before validation.""" + return super().to_internal_value( + { + "id": str(data.get("id")), + "amount": data.get("amount").amount_as_string(), + "due_date": data.get("due_date").isoformat(), + "state": data.get("state"), + } + ) + + def get_currency(self, *args, **kwargs) -> str: + """Return the code of currency used by the instance""" + return settings.DEFAULT_CURRENCY + + def create(self, validated_data): + """Only there to avoid a NotImplementedError""" + + def update(self, instance, validated_data): + """Only there to avoid a NotImplementedError""" + + +class AdminCreditCardSerializer(serializers.ModelSerializer): + """Read only Serializer for CreditCard model.""" + + class Meta: + model = payment_models.CreditCard + fields = [ + "id", + "brand", + "expiration_month", + "expiration_year", + "last_numbers", + ] + read_only_fields = fields + + class AdminOrderSerializer(serializers.ModelSerializer): """Read only Serializer for Order model.""" @@ -1067,6 +1124,8 @@ class AdminOrderSerializer(serializers.ModelSerializer): main_invoice = AdminInvoiceSerializer() organization = AdminOrganizationLightSerializer(read_only=True) order_group = AdminOrderGroupSerializer(read_only=True) + payment_schedule = AdminOrderPaymentSerializer(many=True, read_only=True) + credit_card = AdminCreditCardSerializer(read_only=True) class Meta: model = models.Order @@ -1085,7 +1144,8 @@ class Meta: "contract", "certificate", "main_invoice", - "has_consent_to_terms", + "payment_schedule", + "credit_card", ) read_only_fields = fields diff --git a/src/backend/joanie/core/serializers/client.py b/src/backend/joanie/core/serializers/client.py index 4d3bf8ef9..15a580d53 100644 --- a/src/backend/joanie/core/serializers/client.py +++ b/src/backend/joanie/core/serializers/client.py @@ -16,6 +16,8 @@ from joanie.core import enums, models from joanie.core.serializers.base import CachedModelSerializer from joanie.core.serializers.fields import ISO8601DurationField, ThumbnailDetailField +from joanie.payment.models import CreditCard +from joanie.signature.backends import get_signature_backend class AbilitiesModelSerializer(serializers.ModelSerializer): @@ -445,11 +447,30 @@ class Meta: class ContractLightSerializer(serializers.ModelSerializer): """Light serializer for Contract model.""" + student_signed_on = serializers.SerializerMethodField() + class Meta: model = models.Contract fields = ["id", "organization_signed_on", "student_signed_on"] read_only_fields = fields + def get_student_signed_on(self, contract): + """ + Returns if the student has signed the document. + """ + if ( + contract.submitted_for_signature_on + and not contract.student_signed_on + and not contract.organization_signed_on + ): + signature_backend = get_signature_backend() + signature_state = signature_backend.get_signature_state( + reference_id=contract.signature_backend_reference + ) + return signature_state.get("student") + + return contract.student_signed_on + class ContractSerializer(AbilitiesModelSerializer): """Serializer for Contract model serializer""" @@ -673,6 +694,48 @@ def get_orders(self, instance) -> list[dict]: ).data +class OrderPaymentSerializer(serializers.Serializer): + """ + Serializer for the order payment + """ + + id = serializers.UUIDField(required=True) + amount = serializers.DecimalField( + coerce_to_string=False, + decimal_places=2, + max_digits=9, + min_value=D(0.00), + required=True, + ) + currency = serializers.SerializerMethodField(read_only=True) + due_date = serializers.DateField(required=True) + state = serializers.ChoiceField( + choices=enums.PAYMENT_STATE_CHOICES, + required=True, + ) + + def to_internal_value(self, data): + """Used to format the amount and the due_date before validation.""" + return super().to_internal_value( + { + "id": str(data.get("id")), + "amount": data.get("amount").amount_as_string(), + "due_date": data.get("due_date").isoformat(), + "state": data.get("state"), + } + ) + + def get_currency(self, *args, **kwargs) -> str: + """Return the code of currency used by the instance""" + return settings.DEFAULT_CURRENCY + + def create(self, validated_data): + """Only there to avoid a NotImplementedError""" + + def update(self, instance, validated_data): + """Only there to avoid a NotImplementedError""" + + class OrderLightSerializer(serializers.ModelSerializer): """Order model light serializer.""" @@ -682,6 +745,7 @@ class OrderLightSerializer(serializers.ModelSerializer): certificate_id = serializers.SlugRelatedField( queryset=models.Certificate.objects.all(), slug_field="id", source="certificate" ) + payment_schedule = OrderPaymentSerializer(many=True, read_only=True) class Meta: model = models.Order @@ -690,6 +754,7 @@ class Meta: "certificate_id", "product_id", "state", + "payment_schedule", ] read_only_fields = fields @@ -1030,48 +1095,6 @@ class Meta: read_only_fields = ["id", "created_on"] -class OrderPaymentSerializer(serializers.Serializer): - """ - Serializer for the order payment - """ - - id = serializers.UUIDField(required=True) - amount = serializers.DecimalField( - coerce_to_string=False, - decimal_places=2, - max_digits=9, - min_value=D(0.00), - required=True, - ) - currency = serializers.SerializerMethodField(read_only=True) - due_date = serializers.DateField(required=True) - state = serializers.ChoiceField( - choices=enums.PAYMENT_STATE_CHOICES, - required=True, - ) - - def to_internal_value(self, data): - """Used to format the amount and the due_date before validation.""" - return super().to_internal_value( - { - "id": str(data.get("id")), - "amount": data.get("amount").amount_as_string(), - "due_date": data.get("due_date").isoformat(), - "state": data.get("state"), - } - ) - - def get_currency(self, *args, **kwargs) -> str: - """Return the code of currency used by the instance""" - return settings.DEFAULT_CURRENCY - - def create(self, validated_data): - """Only there to avoid a NotImplementedError""" - - def update(self, instance, validated_data): - """Only there to avoid a NotImplementedError""" - - class OrderPaymentScheduleSerializer(serializers.Serializer): """ Serializer for the order payment schedule @@ -1129,8 +1152,13 @@ class OrderSerializer(serializers.ModelSerializer): read_only=True, slug_field="id", source="certificate" ) contract = ContractSerializer(read_only=True, exclude_abilities=True) - has_consent_to_terms = serializers.BooleanField(write_only=True) payment_schedule = OrderPaymentSerializer(many=True, read_only=True) + credit_card_id = serializers.SlugRelatedField( + queryset=CreditCard.objects.all(), + slug_field="id", + source="credit_card", + required=False, + ) class Meta: model = models.Order @@ -1139,6 +1167,7 @@ class Meta: "contract", "course", "created_on", + "credit_card_id", "enrollment", "id", "main_invoice_reference", @@ -1151,29 +1180,24 @@ class Meta: "target_enrollments", "total", "total_currency", - "has_consent_to_terms", "payment_schedule", ] read_only_fields = fields def get_target_enrollments(self, order) -> list[dict]: """ - For the current order, retrieve its related enrollments. + For the current order, retrieve its related enrollments if the order is linked + to a course. """ + if order.enrollment: + return [] + return EnrollmentSerializer( instance=order.get_target_enrollments(), many=True, context=self.context, ).data - def validate_has_consent_to_terms(self, value): - """Check that user has accepted terms and conditions.""" - if not value: - message = _("You must accept the terms and conditions to proceed.") - raise serializers.ValidationError(message) - - return value - def create(self, validated_data): """ Create a new order and set the organization if provided. @@ -1196,7 +1220,6 @@ def update(self, instance, validated_data): validated_data.pop("organization", None) validated_data.pop("product", None) validated_data.pop("order_group", None) - validated_data.pop("has_consent_to_terms", None) return super().update(instance, validated_data) def get_total_currency(self, *args, **kwargs) -> str: diff --git a/src/backend/joanie/core/tasks/payment_schedule.py b/src/backend/joanie/core/tasks/payment_schedule.py index 8c4418ec6..b6f2b5e66 100644 --- a/src/backend/joanie/core/tasks/payment_schedule.py +++ b/src/backend/joanie/core/tasks/payment_schedule.py @@ -2,39 +2,51 @@ from logging import getLogger -from django.utils import timezone - from joanie.celery_app import app -from joanie.core import enums from joanie.core.models import Order +from joanie.core.utils.payment_schedule import ( + is_installment_to_debit, + send_mail_reminder_for_installment_debit, +) from joanie.payment import get_payment_backend -from joanie.payment.models import CreditCard logger = getLogger(__name__) @app.task -def process_today_installment(order_id): +def debit_pending_installment(order_id): """ - Process the payment schedule for the order. + Process the payment schedule for the order. We debit all pending installments + with a due date less than or equal to today. """ order = Order.objects.get(id=order_id) - today = timezone.localdate() for installment in order.payment_schedule: - if ( - installment["due_date"] == today.isoformat() - and installment["state"] == enums.PAYMENT_STATE_PENDING - ): + if is_installment_to_debit(installment): payment_backend = get_payment_backend() - try: - credit_card = CreditCard.objects.get(owner=order.owner, is_main=True) - except CreditCard.DoesNotExist: + if not order.credit_card or not order.credit_card.token: order.set_installment_refused(installment["id"]) continue payment_backend.create_zero_click_payment( order=order, - credit_card_token=credit_card.token, + credit_card_token=order.credit_card.token, installment=installment, ) + + +@app.task +def send_mail_reminder_installment_debit_task(order_id, installment_id): + """ + Task to send an email reminder to the order's owner about the next installment debit. + """ + order = Order.objects.get(id=order_id) + installment = next( + ( + installment + for installment in order.payment_schedule + if installment["id"] == installment_id + ), + None, + ) + send_mail_reminder_for_installment_debit(order, installment) diff --git a/src/backend/joanie/core/templates/debug/payment.html b/src/backend/joanie/core/templates/debug/payment.html index cd21cd77d..dde76fd98 100644 --- a/src/backend/joanie/core/templates/debug/payment.html +++ b/src/backend/joanie/core/templates/debug/payment.html @@ -31,6 +31,7 @@ One click Payment {% endif %} Tokenize card + Tokenize card for user Zero click Payment @@ -40,6 +41,8 @@

One click Payment

Zero click Payment

{% elif tokenize_card %}

Tokenize card

+ {% elif tokenize_card_user %} +

Tokenize card user

{% else %}

Payment

{% endif %} diff --git a/src/backend/joanie/core/templates/issuers/contract_definition.html b/src/backend/joanie/core/templates/issuers/contract_definition.html index 24babc6fb..02010d7c0 100644 --- a/src/backend/joanie/core/templates/issuers/contract_definition.html +++ b/src/backend/joanie/core/templates/issuers/contract_definition.html @@ -123,14 +123,8 @@

{{ contract.title }}

{% if contract %} {{ contract.body|safe }} {% endif %} - {% if contract.terms_and_conditions or syllabus %} -

{% translate "Appendices" %}

- {% endif %} - {% if contract.terms_and_conditions %} -

{% translate "Terms and conditions" %}

- {{ contract.terms_and_conditions|safe }} - {% endif %} {% if syllabus %} +

{% translate "Appendices" %}

{% translate "Catalog syllabus" %}

{% 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": "

Articles de la convention

", - "terms_and_conditions": "

Terms and conditions

", "description": "Contract definition description", "title": "CONTRACT DEFINITION 1", "language": "en-us", @@ -160,7 +170,7 @@ def test_utils_contract_definition_generate_document_context_with_order(self): "title": address_organization.title, "is_main": address_organization.is_main, }, - "logo": image_to_base64(order.organization.logo), + "logo_id": str(organization_logo.id), "name": organization.title, "representative": organization.representative, "representative_profession": organization.representative_profession, @@ -176,12 +186,6 @@ def test_utils_contract_definition_generate_document_context_with_order(self): }, } - context = contract_definition.generate_document_context( - contract_definition=order.product.contract_definition, - user=user, - order=order, - ) - self.assertEqual(context, expected_context) def test_utils_contract_definition_generate_document_context_without_order(self): @@ -197,10 +201,6 @@ def test_utils_contract_definition_generate_document_context_without_order(self) `organization.contact_email` `organization.dpo_email`, `organization.representative_profession`. """ - organization_fallback_logo = ( - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR" - "42mO8cPX6fwAIdgN9pHTGJwAAAABJRU5ErkJggg==" - ) user = factories.UserFactory( email="student@example.fr", first_name="John Doe", @@ -216,7 +216,6 @@ def test_utils_contract_definition_generate_document_context_without_order(self) expected_context = { "contract": { "body": "

Articles de la convention

", - "terms_and_conditions": "", "description": "Contract definition description", "title": "CONTRACT DEFINITION 2", "language": "fr-fr", @@ -256,7 +255,7 @@ def test_utils_contract_definition_generate_document_context_without_order(self) "title": "", "is_main": True, }, - "logo": organization_fallback_logo, + "logo_id": None, "name": "", "representative": "", "representative_profession": "", @@ -284,10 +283,6 @@ def test_utils_contract_definition_generate_document_context_default_placeholder and a user, it should return the default placeholder values for different sections of the context. """ - organization_fallback_logo = ( - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR" - "42mO8cPX6fwAIdgN9pHTGJwAAAABJRU5ErkJggg==" - ) definition = factories.ContractDefinitionFactory( title="CONTRACT DEFINITION 3", description="Contract definition description", @@ -297,7 +292,6 @@ def test_utils_contract_definition_generate_document_context_default_placeholder expected_context = { "contract": { "body": "

Articles de la convention

", - "terms_and_conditions": "", "description": "Contract definition description", "title": "CONTRACT DEFINITION 3", "language": "fr-fr", @@ -337,7 +331,7 @@ def test_utils_contract_definition_generate_document_context_default_placeholder "title": "", "is_main": True, }, - "logo": organization_fallback_logo, + "logo_id": None, "name": "", "representative": "", "representative_profession": "", @@ -359,15 +353,10 @@ def test_utils_contract_definition_generate_document_context_default_placeholder @override_settings( JOANIE_DOCUMENT_ISSUER_CONTEXT_PROCESSORS={ - "contract_definition": [ - "joanie.tests.core.utils.test_contract_definition_generate_document_context._processor_for_test_suite" # pylint: disable=line-too-long - ] + "contract_definition": [PROCESSOR_PATH] } ) - @mock.patch( - "joanie.tests.core.utils.test_contract_definition_generate_document_context._processor_for_test_suite", # pylint: disable=line-too-long - side_effect=_processor_for_test_suite, - ) + @mock.patch(PROCESSOR_PATH, side_effect=_processor_for_test_suite) def test_utils_contract_definition_generate_document_context_processors( self, _mock_processor_for_test ): @@ -435,10 +424,6 @@ def test_utils_contract_definition_generate_document_context_course_data_section 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", @@ -501,7 +486,7 @@ def test_utils_contract_definition_generate_document_context_course_data_section 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( @@ -554,3 +539,168 @@ def test_utils_contract_definition_generate_document_context_course_data_section self.assertIsInstance(contract.context["course"]["price"], str) self.assertEqual(order.total, Decimal("999.99")) self.assertEqual(contract.context["course"]["price"], "999.99") + + @override_settings( + JOANIE_DOCUMENT_ISSUER_CONTEXT_PROCESSORS={ + "contract_definition": [PROCESSOR_PATH] + } + ) + @mock.patch(PROCESSOR_PATH, side_effect=_processor_for_test_suite) + def test_utils_contract_definition_generate_document_context_processors_with_syllabus( + self, mock_processor_for_test + ): + """ + If contract definition context processors are defined through settings, those should be + called and their results should be merged into the final context. We should find the terms + and conditions within the body of the contract and the `appendices` section with the + syllabus context in the document. + """ + user = factories.UserFactory( + email="johndoe@example.fr", + first_name="John Doe", + last_name="", + phone_number="0123456789", + ) + user_address = factories.UserAddressFactory( + owner=user, + first_name="John", + last_name="Doe", + address="5 Rue de L'Exemple", + postcode="75000", + city="Paris", + country="FR", + title="Office", + is_main=False, + ) + organization = factories.OrganizationFactory( + dpo_email="johnnydoes@example.fr", + contact_email="contact@example.fr", + contact_phone="0123456789", + enterprise_code="1234", + activity_category_code="abcd1234", + representative="Mister Example", + representative_profession="Educational representative", + signatory_representative="Big boss", + signatory_representative_profession="Director", + ) + factories.OrganizationAddressFactory( + organization=organization, + owner=None, + is_main=True, + is_reusable=True, + ) + relation = factories.CourseProductRelationFactory( + organizations=[organization], + product=factories.ProductFactory( + contract_definition=factories.ContractDefinitionFactory( + title="CONTRACT DEFINITION 4", + description="Contract definition description", + body=""" + ## Articles de la convention + ## Terms and conditions + Terms and conditions content + """, + language="fr-fr", + ), + title="You will know that you know you don't know", + price="999.99", + target_courses=[ + factories.CourseFactory( + course_runs=[ + factories.CourseRunFactory( + start="2024-01-01T09:00:00+00:00", + end="2024-03-31T18:00:00+00:00", + enrollment_start="2024-01-01T12:00:00+00:00", + enrollment_end="2024-02-01T12:00:00+00:00", + ) + ] + ) + ], + ), + course=factories.CourseFactory( + organizations=[organization], + effort=timedelta(hours=10, minutes=30, seconds=12), + ), + ) + order = factories.OrderFactory( + owner=user, + product=relation.product, + course=relation.course, + state=enums.ORDER_STATE_ASSIGNED, + main_invoice=InvoiceFactory(recipient_address=user_address), + ) + factories.ContractFactory(order=order) + factories.OrderTargetCourseRelationFactory( + course=relation.course, order=order, position=1 + ) + context = contract_definition.generate_document_context( + contract_definition=order.contract.definition, + user=user, + order=order, + ) + context["syllabus"] = "Syllabus Test" + mock_processor_for_test.assert_called_once_with(context) + + file_bytes = issuers.generate_document( + name=order.contract.definition.name, + context=context, + ) + document_text = pdf_extract_text(BytesIO(file_bytes)).replace("\n", "") + + self.assertEqual( + context["extra"], + { + "course_code": relation.course.code, + "language_code": "fr-fr", + "is_for_test": True, + }, + ) + self.assertRegex(document_text, r"John Doe") + self.assertRegex(document_text, r"Terms and conditions") + self.assertRegex(document_text, r"Session start date") + self.assertRegex(document_text, r"01/01/2024 9 a.m.") + self.assertRegex(document_text, r"Session end date") + self.assertRegex(document_text, r"03/31/2024 6 p.m") + self.assertRegex(document_text, r"Price of the course") + self.assertRegex(document_text, r"999.99 €") + self.assertRegex(document_text, r"Appendices") + self.assertRegex(document_text, r"Syllabus Test") + self.assertRegex(document_text, r"[SignatureField#1]") + self.assertRegex(document_text, r"[SignatureField#2]") + + def test_embed_images_in_context(self): + """ + It should embed the images in the context. + """ + organization = factories.OrganizationFactory() + logo = DocumentImage.objects.create(file=organization.logo, checksum="123abc") + context = {"organization": {"logo_id": str(logo.id)}} + + context_with_images = contract_definition.embed_images_in_context(context) + + self.assertEqual( + context_with_images["organization"]["logo"], + image_to_base64(organization.logo), + ) + self.assertNotIn("logo_id", context_with_images["organization"]) + + # Initial context should not be modified + self.assertNotIn("logo", context["organization"]) + self.assertEqual(context["organization"]["logo_id"], str(logo.id)) + + def test_embed_images_in_context_no_document_image(self): + """ + It should embed default image in the context when the document image is not found. + """ + context = {"organization": {"logo_id": None}} + + context_with_images = contract_definition.embed_images_in_context(context) + + self.assertEqual( + context_with_images["organization"]["logo"], ORGANIZATION_FALLBACK_LOGO + ) + self.assertNotIn("logo_id", context_with_images["organization"]) + + # Initial context should not be modified + self.assertNotIn("logo", context["organization"]) + self.assertIsNone(context["organization"]["logo_id"]) diff --git a/src/backend/joanie/tests/core/utils/test_course_run.py b/src/backend/joanie/tests/core/utils/test_course_run.py index a176f1fc8..82c90f4da 100644 --- a/src/backend/joanie/tests/core/utils/test_course_run.py +++ b/src/backend/joanie/tests/core/utils/test_course_run.py @@ -84,7 +84,7 @@ def test_utils_course_run_where_student_enrolls_and_makes_an_order_to_access_to_ 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/utils/test_emails_prepare_context_data.py b/src/backend/joanie/tests/core/utils/test_emails_prepare_context_data.py new file mode 100644 index 000000000..3624088d4 --- /dev/null +++ b/src/backend/joanie/tests/core/utils/test_emails_prepare_context_data.py @@ -0,0 +1,208 @@ +"""Test suite for `prepare_context_data` email utility for installment payments""" + +from datetime import date +from decimal import Decimal + +from django.conf import settings +from django.test import TestCase, override_settings + +from stockholm import Money + +from joanie.core.enums import ( + ORDER_STATE_PENDING_PAYMENT, + PAYMENT_STATE_PAID, + PAYMENT_STATE_REFUSED, +) +from joanie.core.factories import OrderGeneratorFactory, ProductFactory, UserFactory +from joanie.core.utils.emails import ( + prepare_context_data, + prepare_context_for_upcoming_installment, +) + + +@override_settings( + JOANIE_CATALOG_NAME="Test Catalog", + JOANIE_CATALOG_BASE_URL="https://richie.education", + JOANIE_INSTALLMENT_REMINDER_PERIOD_DAYS=2, + JOANIE_PAYMENT_SCHEDULE_LIMITS={ + 1000: (20, 30, 30, 20), + }, + DEFAULT_CURRENCY="EUR", +) +class UtilsEmailPrepareContextDataInstallmentPaymentTestCase(TestCase): + """ + Test suite for `prepare_context_data` for email utility when installment is paid or refused + """ + + maxDiff = None + + def test_utils_emails_prepare_context_data_when_installment_debit_is_successful( + self, + ): + """ + When an installment is successfully paid, the `prepare_context_data` method should + create the context with the following keys : `fullname`, `email`, `product_title`, + `installment_amount`, `product_price`, `credit_card_last_numbers`, + `order_payment_schedule`, `dashboard_order_link`, `site`, `remaining_balance_to_pay`, + `date_next_installment_to_pay`, and `targeted_installment_index`. + """ + product = ProductFactory(price=Decimal("1000.00"), title="Product 1") + order = OrderGeneratorFactory( + product=product, + state=ORDER_STATE_PENDING_PAYMENT, + owner=UserFactory( + first_name="John", + last_name="Doe", + language="en-us", + email="johndoe@fun-test.fr", + ), + ) + order.payment_schedule[0]["state"] = PAYMENT_STATE_PAID + order.payment_schedule[1]["state"] = PAYMENT_STATE_PAID + order.payment_schedule[2]["due_date"] = date(2024, 3, 17) + order.save() + + context_data = prepare_context_data( + order, + order.payment_schedule[2]["amount"], + product.title, + payment_refused=False, + ) + + self.assertDictEqual( + context_data, + { + "fullname": "John Doe", + "email": "johndoe@fun-test.fr", + "product_title": "Product 1", + "installment_amount": Money("300.00"), + "product_price": Money("1000.00"), + "credit_card_last_numbers": order.credit_card.last_numbers, + "order_payment_schedule": order.payment_schedule, + "dashboard_order_link": ( + f"http://localhost:8070/dashboard/courses/orders/{order.id}/" + ), + "site": { + "name": "Test Catalog", + "url": "https://richie.education", + }, + "remaining_balance_to_pay": Money("500.00"), + "date_next_installment_to_pay": date(2024, 3, 17), + "targeted_installment_index": 1, + }, + ) + + def test_utils_emails_prepare_context_data_when_installment_debit_is_refused(self): + """ + When an installment debit has been refused, the `prepare_context_data` method should + create the context and we should not find the following keys : `remaining_balance_to_pay`, + and `date_next_installment_to_pay`. + """ + product = ProductFactory(price=Decimal("1000.00"), title="Product 1") + order = OrderGeneratorFactory( + product=product, + state=ORDER_STATE_PENDING_PAYMENT, + owner=UserFactory( + first_name="John", + last_name="Doe", + language="en-us", + email="johndoe@fun-test.fr", + ), + ) + order.payment_schedule[0]["state"] = PAYMENT_STATE_PAID + order.payment_schedule[1]["state"] = PAYMENT_STATE_PAID + order.payment_schedule[2]["state"] = PAYMENT_STATE_REFUSED + order.payment_schedule[2]["due_date"] = date(2024, 3, 17) + order.save() + + context_data = prepare_context_data( + order, + order.payment_schedule[2]["amount"], + product.title, + payment_refused=True, + ) + + self.assertNotIn("remaining_balance_to_pay", context_data) + self.assertNotIn("date_next_installment_to_pay", context_data) + self.assertDictEqual( + context_data, + { + "fullname": "John Doe", + "email": "johndoe@fun-test.fr", + "product_title": "Product 1", + "installment_amount": Money("300.00"), + "product_price": Money("1000.00"), + "credit_card_last_numbers": order.credit_card.last_numbers, + "order_payment_schedule": order.payment_schedule, + "dashboard_order_link": ( + f"http://localhost:8070/dashboard/courses/orders/{order.id}/" + ), + "site": { + "name": "Test Catalog", + "url": "https://richie.education", + }, + "targeted_installment_index": 2, + }, + ) + + def test_utils_emails_prepare_context_for_upcoming_installment_email( + self, + ): + """ + When an installment will soon be debited for the order's owners, the method + `prepare_context_for_upcoming_installment` will prepare the context variable that + will be used for the email. + + We should find the following keys : `fullname`, `email`, `product_title`, + `installment_amount`, `product_price`, `credit_card_last_numbers`, + `order_payment_schedule`, `dashboard_order_link`, `site`, `remaining_balance_to_pay`, + `date_next_installment_to_pay`, `targeted_installment_index`, and `days_until_debit` + """ + product = ProductFactory(price=Decimal("1000.00"), title="Product 1") + order = OrderGeneratorFactory( + product=product, + state=ORDER_STATE_PENDING_PAYMENT, + owner=UserFactory( + first_name="John", + last_name="Doe", + language="en-us", + email="johndoe@fun-test.fr", + ), + ) + order.payment_schedule[0]["state"] = PAYMENT_STATE_PAID + order.payment_schedule[1]["state"] = PAYMENT_STATE_PAID + order.payment_schedule[2]["due_date"] = date(2024, 3, 17) + order.save() + + context_data_for_upcoming_installment_email = ( + prepare_context_for_upcoming_installment( + order, + order.payment_schedule[2]["amount"], + product.title, + days_until_debit=settings.JOANIE_INSTALLMENT_REMINDER_PERIOD_DAYS, + ) + ) + + self.assertDictEqual( + context_data_for_upcoming_installment_email, + { + "fullname": "John Doe", + "email": "johndoe@fun-test.fr", + "product_title": "Product 1", + "installment_amount": Money("300.00"), + "product_price": Money("1000.00"), + "credit_card_last_numbers": order.credit_card.last_numbers, + "order_payment_schedule": order.payment_schedule, + "dashboard_order_link": ( + f"http://localhost:8070/dashboard/courses/orders/{order.id}/" + ), + "site": { + "name": "Test Catalog", + "url": "https://richie.education", + }, + "remaining_balance_to_pay": Money("500.00"), + "date_next_installment_to_pay": date(2024, 3, 17), + "targeted_installment_index": 2, + "days_until_debit": 2, + }, + ) diff --git a/src/backend/joanie/tests/core/utils/test_issuers_contract_definition_generate_document.py b/src/backend/joanie/tests/core/utils/test_issuers_contract_definition_generate_document.py index ab2591483..64643ffba 100644 --- a/src/backend/joanie/tests/core/utils/test_issuers_contract_definition_generate_document.py +++ b/src/backend/joanie/tests/core/utils/test_issuers_contract_definition_generate_document.py @@ -24,11 +24,6 @@ def test_utils_issuers_contract_definition_generate_document(self): """ Issuer 'generate document' method should generate a contract definition document. """ - factories.SiteConfigFactory( - site=Site.objects.get_current(), - terms_and_conditions="Terms and Conditions Content", - ) - user = factories.UserFactory( email="student@example.fr", first_name="Rooky", @@ -39,7 +34,11 @@ def test_utils_issuers_contract_definition_generate_document(self): definition = factories.ContractDefinitionFactory( title="Contract Definition Title", description="Contract Definition Description", - body="## Contract Definition Body", + body=""" + ## Contract Definition Body + ## Terms and conditions + Terms and Conditions Content + """, ) organization = factories.OrganizationFactory( @@ -95,7 +94,7 @@ def test_utils_issuers_contract_definition_generate_document(self): product=relation.product, course=relation.course, organization=organization, - state=enums.ORDER_STATE_VALIDATED, + state=enums.ORDER_STATE_COMPLETED, main_invoice=InvoiceFactory( recipient_address=factories.UserAddressFactory( owner=user, @@ -155,12 +154,13 @@ def test_utils_issuers_contract_definition_generate_document(self): # - Contract content should be displayed self.assertIn("Contract Definition Body", document_text) - - # - Appendices should be displayed - self.assertIn("Appendices", document_text) self.assertIn("Terms and conditions", document_text) self.assertIn("Terms and Conditions Content", document_text) + # - Appendices should be displayed + self.assertNotIn("Appendices", document_text) + self.assertNotIn("Syllabus", document_text) + # - Signature slots should be displayed self.assertIn("Learner's signature", document_text) self.assertIn("[SignatureField#1]", document_text) @@ -182,7 +182,11 @@ def test_utils_issuers_contract_definition_generate_document_with_placeholders( definition = factories.ContractDefinitionFactory( title="Contract Definition Title", description="Contract Definition Description", - body="## Contract Definition Body", + body=""" + ## Contract Definition Body, + ## Terms and conditions + Terms and Conditions Content + """, ) context = contract_definition_utility.generate_document_context( @@ -242,12 +246,13 @@ def test_utils_issuers_contract_definition_generate_document_with_placeholders( # - Contract content should be displayed self.assertIn("Contract Definition Body", document_text) - - # - Appendices should be displayed - self.assertIn("Appendices", document_text) self.assertIn("Terms and conditions", document_text) self.assertIn("Terms and Conditions Content", document_text) + # - Appendices should be displayed + self.assertNotIn("Appendices", document_text) + self.assertNotIn("Syllabus", document_text) + # - Signature slots should be displayed self.assertIn("Learner's signature", document_text) self.assertIn("[SignatureField#1]", document_text) diff --git a/src/backend/joanie/tests/core/utils/test_issuers_generate_document.py b/src/backend/joanie/tests/core/utils/test_issuers_generate_document.py index 0e8c87e01..bebc9db91 100644 --- a/src/backend/joanie/tests/core/utils/test_issuers_generate_document.py +++ b/src/backend/joanie/tests/core/utils/test_issuers_generate_document.py @@ -30,20 +30,15 @@ def test_utils_issuers_generate_document(self): ## Article 3 The student has paid in advance the whole course before the start - """ - markdown_terms_and_conditions = """ + ## Terms and conditions Here are the terms and conditions of the current contract """ body_content = markdown.markdown(textwrap.dedent(markdown_content)) - terms_and_conditions_content = markdown.markdown( - textwrap.dedent(markdown_terms_and_conditions) - ) context = { "contract": { "body": body_content, - "terms_and_conditions": terms_and_conditions_content, "title": "Contract Definition", "description": "This is the contract definition", }, diff --git a/src/backend/joanie/tests/demo/test_commands_create_dev_demo.py b/src/backend/joanie/tests/demo/test_commands_create_dev_demo.py index 1557cf5a3..267be1e1b 100644 --- a/src/backend/joanie/tests/demo/test_commands_create_dev_demo.py +++ b/src/backend/joanie/tests/demo/test_commands_create_dev_demo.py @@ -50,7 +50,7 @@ def test_commands_create_dev_demo(self): nb_product_credential += ( 1 # create_product_credential_purchased with installment payment failed ) - nb_product_credential += 5 # one order of each state + nb_product_credential += 11 # one order of each state nb_product = nb_product_credential + nb_product_certificate nb_product += 1 # Become a certified botanist gradeo diff --git a/src/backend/joanie/tests/lms_handler/test_backend_openedx.py b/src/backend/joanie/tests/lms_handler/test_backend_openedx.py index f8c135faf..213655bbc 100644 --- a/src/backend/joanie/tests/lms_handler/test_backend_openedx.py +++ b/src/backend/joanie/tests/lms_handler/test_backend_openedx.py @@ -12,8 +12,9 @@ import responses from requests import RequestException -from joanie.core import factories, models +from joanie.core import enums, factories, models from joanie.core.exceptions import EnrollmentError, GradeError +from joanie.core.models import Order from joanie.lms_handler import LMSHandler from joanie.lms_handler.backends.openedx import ( OPENEDX_MODE_HONOR, @@ -278,7 +279,7 @@ def test_backend_openedx_set_enrollment_with_preexisting_enrollment(self): order = factories.OrderFactory(product=product, owner=user) self.assertEqual(len(responses.calls), 0) - order.submit() + order.init_flow() self.assertEqual(len(responses.calls), 2) self.assertEqual(responses.calls[1].request.url, url) @@ -377,7 +378,7 @@ def test_backend_openedx_set_enrollment_with_related_certificate_product(self): enrollment=enrollment, product__type="certificate", product__courses=[course_run.course], - state="validated", + state=enums.ORDER_STATE_COMPLETED, ) result = backend.set_enrollment(enrollment) @@ -410,17 +411,90 @@ def test_backend_openedx_set_enrollment_with_related_certificate_product(self): ) order.flow.cancel() + if enrollment.is_active: + self.assertEqual(len(responses.calls), 4) + self.assertEqual( + json.loads(responses.calls[3].request.body), + { + "is_active": is_active, + "mode": "honor", + "user": user.username, + "course_details": {"course_id": "course-v1:edx+000001+Demo_Course"}, + }, + ) + else: + # If enrollment is inactive, no need to update it + self.assertEqual(len(responses.calls), 2) - self.assertEqual(len(responses.calls), 4) - self.assertEqual( - json.loads(responses.calls[3].request.body), - { - "is_active": is_active, - "mode": "honor", - "user": user.username, - "course_details": {"course_id": "course-v1:edx+000001+Demo_Course"}, - }, + @responses.activate + def test_backend_openedx_set_enrollment_states(self): + """ + When updating a user's enrollment, the mode should be set to "verified" if the user has + an order in a state that allows enrollment. + """ + course_id = "course-v1:edx+000001+Demo_Course" + resource_link = f"http://openedx.test/courses/{course_id}/course" + course_run = factories.CourseRunFactory( + is_listed=True, + resource_link=resource_link, + state=models.CourseState.ONGOING_OPEN, ) + user = factories.UserFactory() + is_active = random.choice([True, False]) + + url = f"http://openedx.test/api/enrollment/v1/enrollment/{user.username},{course_id}" + responses.add( + responses.GET, + url, + status=HTTPStatus.OK, + json={"is_active": not is_active, "mode": OPENEDX_MODE_HONOR}, + ) + + url = "http://openedx.test/api/enrollment/v1/enrollment" + responses.add( + responses.POST, + url, + status=HTTPStatus.OK, + json={"is_active": is_active}, + ) + + enrollment = factories.EnrollmentFactory( + course_run=course_run, + user=user, + is_active=is_active, + ) + + for state, _ in enums.ORDER_STATE_CHOICES: + with self.subTest(state=state): + responses.calls.reset() # pylint: disable=no-member + Order.objects.all().delete() + + backend = LMSHandler.select_lms(resource_link) + + factories.OrderFactory( + course=None, + enrollment=enrollment, + product__type="certificate", + product__courses=[course_run.course], + state=state, + ) + result = backend.set_enrollment(enrollment) + + self.assertIsNone(result) + self.assertEqual(len(responses.calls), 2) + self.assertEqual( + json.loads(responses.calls[1].request.body), + { + "is_active": is_active, + "mode": OPENEDX_MODE_VERIFIED + if state in enums.ORDER_STATE_ALLOW_ENROLLMENT + else OPENEDX_MODE_HONOR, + "user": user.username, + "course_details": { + "course_id": "course-v1:edx+000001+Demo_Course" + }, + }, + ) @responses.activate def test_backend_openedx_set_enrollment_without_changes(self): diff --git a/src/backend/joanie/tests/payment/base_payment.py b/src/backend/joanie/tests/payment/base_payment.py index 6510c9af3..bdf938850 100644 --- a/src/backend/joanie/tests/payment/base_payment.py +++ b/src/backend/joanie/tests/payment/base_payment.py @@ -3,37 +3,68 @@ from django.core import mail from django.test import TestCase +from parler.utils.context import switch_language + +from joanie.core.enums import ORDER_STATE_COMPLETED + class BasePaymentTestCase(TestCase): """Common method to test the Payment Backend""" maxDiff = None - def _check_order_validated_email_sent(self, email, username, order): - """Shortcut to check order validated email has been sent""" - # check email has been sent - self.assertEqual(len(mail.outbox), 1) - + def _check_installment_paid_email_sent(self, email, order): + """Shortcut to check over installment paid email has been sent""" # check we send it to the right email self.assertEqual(mail.outbox[0].to[0], email) + # check it's the right object + if order.state == ORDER_STATE_COMPLETED: + self.assertIn( + "Order completed ! The last installment of", + mail.outbox[0].subject, + ) + else: + self.assertIn( + "An installment has been successfully paid", + mail.outbox[0].subject, + ) + + # Check body 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) + fullname = order.owner.get_full_name() + self.assertIn(f"Hello {fullname}", email_content) + self.assertIn("has been debited on the credit card", email_content) + self.assertIn("See order details on your dashboard", email_content) self.assertIn(order.product.title, email_content) - # check it's the right object - self.assertEqual(mail.outbox[0].subject, "Purchase order confirmed!") + # 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) - if username: - self.assertIn(f"Hello {username}", email_content) - else: - self.assertIn("Hello", email_content) - self.assertNotIn("None", email_content) + def _check_installment_refused_email_sent(self, email, order): + """Shortcut to check over installment debit is refused email has been sent""" + # Check we send it to the right email + self.assertEqual(mail.outbox[0].to[0], email) + + self.assertIn("An installment debit has failed", 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 debit has failed.", email_content) + self.assertIn( + "Please correct the failed payment as soon as possible using", email_content + ) + # Check the product title is in the correct language + with switch_language(order.product, order.owner.language): + self.assertIn(order.product.title, 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) diff --git a/src/backend/joanie/tests/payment/lyra/requests/payment_accepted_no_store_card.json b/src/backend/joanie/tests/payment/lyra/requests/payment_accepted_no_store_card.json index 5344b8a1e..5113bbbd7 100644 --- a/src/backend/joanie/tests/payment/lyra/requests/payment_accepted_no_store_card.json +++ b/src/backend/joanie/tests/payment/lyra/requests/payment_accepted_no_store_card.json @@ -1,7 +1 @@ -{ - "kr-hash-key": "password", - "kr-hash-algorithm": "sha256_hmac", - "kr-answer": "{\"shopId\":\"69876357\",\"orderCycle\":\"CLOSED\",\"orderStatus\":\"PAID\",\"serverDate\":\"2024-04-11T08:31:08+00:00\",\"orderDetails\":{\"orderTotalAmount\":12345,\"orderEffectiveAmount\":12345,\"orderCurrency\":\"EUR\",\"mode\":\"TEST\",\"orderId\":\"514070fe-c12c-48b8-97cf-5262708673a3\",\"metadata\":null,\"_type\":\"V4/OrderDetails\"},\"customer\":{\"billingDetails\":{\"address\":\"65368 Ward Plain\",\"category\":null,\"cellPhoneNumber\":null,\"city\":\"West Deborahland\",\"country\":\"SK\",\"district\":null,\"firstName\":\"Elizabeth\",\"identityCode\":null,\"identityType\":null,\"language\":\"FR\",\"lastName\":\"Brady\",\"phoneNumber\":null,\"state\":null,\"streetNumber\":null,\"title\":null,\"zipCode\":\"05597\",\"legalName\":null,\"_type\":\"V4/Customer/BillingDetails\"},\"email\":\"john.doe@acme.org\",\"reference\":null,\"shippingDetails\":{\"address\":null,\"address2\":null,\"category\":null,\"city\":null,\"country\":null,\"deliveryCompanyName\":null,\"district\":null,\"firstName\":null,\"identityCode\":null,\"lastName\":null,\"legalName\":null,\"phoneNumber\":null,\"shippingMethod\":null,\"shippingSpeed\":null,\"state\":null,\"streetNumber\":null,\"zipCode\":null,\"_type\":\"V4/Customer/ShippingDetails\"},\"extraDetails\":{\"browserAccept\":null,\"fingerPrintId\":null,\"ipAddress\":\"86.221.55.189\",\"browserUserAgent\":\"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0\",\"_type\":\"V4/Customer/ExtraDetails\"},\"shoppingCart\":{\"insuranceAmount\":null,\"shippingAmount\":null,\"taxAmount\":null,\"cartItemInfo\":null,\"_type\":\"V4/Customer/ShoppingCart\"},\"_type\":\"V4/Customer/Customer\"},\"transactions\":[{\"shopId\":\"69876357\",\"uuid\":\"b4a819d9e4224247b58ccc861321a94a\",\"amount\":12345,\"currency\":\"EUR\",\"paymentMethodType\":\"CARD\",\"paymentMethodToken\":null,\"status\":\"PAID\",\"detailedStatus\":\"AUTHORISED\",\"operationType\":\"DEBIT\",\"effectiveStrongAuthentication\":\"DISABLED\",\"creationDate\":\"2024-04-11T08:31:07+00:00\",\"errorCode\":null,\"errorMessage\":null,\"detailedErrorCode\":null,\"detailedErrorMessage\":null,\"metadata\":null,\"transactionDetails\":{\"liabilityShift\":\"NO\",\"effectiveAmount\":12345,\"effectiveCurrency\":\"EUR\",\"creationContext\":\"CHARGE\",\"cardDetails\":{\"paymentSource\":\"EC\",\"manualValidation\":\"NO\",\"expectedCaptureDate\":\"2024-04-17T08:31:07+00:00\",\"effectiveBrand\":\"VISA\",\"pan\":\"497010XXXXXX0055\",\"expiryMonth\":12,\"expiryYear\":2025,\"country\":\"FR\",\"issuerCode\":null,\"issuerName\":\"Banque de demo et de l'innovation\",\"effectiveProductCode\":\"F\",\"legacyTransId\":\"941672\",\"legacyTransDate\":\"2024-04-11T08:31:07+00:00\",\"paymentMethodSource\":\"NEW\",\"authorizationResponse\":{\"amount\":12345,\"currency\":\"EUR\",\"authorizationDate\":\"2024-04-11T08:31:07+00:00\",\"authorizationNumber\":\"3fe171\",\"authorizationResult\":\"0\",\"authorizationMode\":\"FULL\",\"_type\":\"V4/PaymentMethod/Details/Cards/CardAuthorizationResponse\"},\"captureResponse\":{\"refundAmount\":null,\"refundCurrency\":null,\"captureDate\":null,\"captureFileNumber\":null,\"effectiveRefundAmount\":null,\"effectiveRefundCurrency\":null,\"_type\":\"V4/PaymentMethod/Details/Cards/CardCaptureResponse\"},\"threeDSResponse\":{\"authenticationResultData\":{\"transactionCondition\":null,\"enrolled\":null,\"status\":null,\"eci\":null,\"xid\":null,\"cavvAlgorithm\":null,\"cavv\":null,\"signValid\":null,\"brand\":null,\"_type\":\"V4/PaymentMethod/Details/Cards/CardAuthenticationResponse\"},\"_type\":\"V4/PaymentMethod/Details/Cards/ThreeDSResponse\"},\"authenticationResponse\":{\"id\":\"aef3f5df-d4f8-4164-8853-b61db36ec52c\",\"operationSessionId\":null,\"protocol\":{\"name\":\"THREEDS\",\"version\":\"2.2.0\",\"network\":\"VISA\",\"challengePreference\":\"NO_PREFERENCE\",\"simulation\":true,\"_type\":\"V4/Charge/Authenticate/Protocol\"},\"value\":{\"authenticationType\":null,\"authenticationId\":null,\"authenticationValue\":null,\"status\":\"NOT_ENROLLED\",\"commerceIndicator\":null,\"extension\":{\"authenticationType\":\"THREEDS_V2\",\"challengeCancelationIndicator\":null,\"cbScore\":null,\"cbAvalgo\":null,\"cbExemption\":null,\"paymentUseCase\":null,\"threeDSServerTransID\":null,\"dsTransID\":null,\"acsTransID\":null,\"sdkTransID\":null,\"transStatusReason\":null,\"requestedExemption\":\"TECHNICAL_ERROR\",\"requestorName\":\"Demo shop\",\"cardHolderInfo\":null,\"dataOnlyStatus\":null,\"dataOnlyDecision\":null,\"dataOnlyScore\":null,\"_type\":\"V4/Charge/Authenticate/AuthenticationResultExtensionThreedsV2\"},\"reason\":null,\"_type\":\"V4/Charge/Authenticate/AuthenticationResult\"},\"_type\":\"V4/AuthenticationResponseData\"},\"installmentNumber\":null,\"installmentCode\":null,\"markAuthorizationResponse\":{\"amount\":null,\"currency\":null,\"authorizationDate\":null,\"authorizationNumber\":null,\"authorizationResult\":null,\"_type\":\"V4/PaymentMethod/Details/Cards/MarkAuthorizationResponse\"},\"cardHolderName\":null,\"cardHolderPan\":\"497010XXXXXX0055\",\"cardHolderExpiryMonth\":12,\"cardHolderExpiryYear\":2025,\"identityDocumentNumber\":null,\"identityDocumentType\":null,\"initialIssuerTransactionIdentifier\":null,\"productCategory\":\"DEBIT\",\"nature\":\"CONSUMER_CARD\",\"_type\":\"V4/PaymentMethod/Details/CardDetails\"},\"paymentMethodDetails\":{\"id\":\"497010XXXXXX0055\",\"paymentSource\":\"EC\",\"manualValidation\":\"NO\",\"expectedCaptureDate\":\"2024-04-17T08:31:07+00:00\",\"effectiveBrand\":\"VISA\",\"expiryMonth\":12,\"expiryYear\":2025,\"country\":\"FR\",\"issuerCode\":null,\"issuerName\":\"Banque de demo et de l'innovation\",\"effectiveProductCode\":\"F\",\"legacyTransId\":\"941672\",\"legacyTransDate\":\"2024-04-11T08:31:07+00:00\",\"paymentMethodSource\":\"NEW\",\"authorizationResponse\":{\"amount\":12345,\"currency\":\"EUR\",\"authorizationDate\":\"2024-04-11T08:31:07+00:00\",\"authorizationNumber\":\"3fe171\",\"authorizationResult\":\"0\",\"authorizationMode\":\"FULL\",\"_type\":\"V4/PaymentMethod/Details/Cards/CardAuthorizationResponse\"},\"captureResponse\":{\"refundAmount\":null,\"refundCurrency\":null,\"captureDate\":null,\"captureFileNumber\":null,\"effectiveRefundAmount\":null,\"effectiveRefundCurrency\":null,\"_type\":\"V4/PaymentMethod/Details/Cards/CardCaptureResponse\"},\"authenticationResponse\":{\"id\":\"aef3f5df-d4f8-4164-8853-b61db36ec52c\",\"operationSessionId\":null,\"protocol\":{\"name\":\"THREEDS\",\"version\":\"2.2.0\",\"network\":\"VISA\",\"challengePreference\":\"NO_PREFERENCE\",\"simulation\":true,\"_type\":\"V4/Charge/Authenticate/Protocol\"},\"value\":{\"authenticationType\":null,\"authenticationId\":null,\"authenticationValue\":null,\"status\":\"NOT_ENROLLED\",\"commerceIndicator\":null,\"extension\":{\"authenticationType\":\"THREEDS_V2\",\"challengeCancelationIndicator\":null,\"cbScore\":null,\"cbAvalgo\":null,\"cbExemption\":null,\"paymentUseCase\":null,\"threeDSServerTransID\":null,\"dsTransID\":null,\"acsTransID\":null,\"sdkTransID\":null,\"transStatusReason\":null,\"requestedExemption\":\"TECHNICAL_ERROR\",\"requestorName\":\"Demo shop\",\"cardHolderInfo\":null,\"dataOnlyStatus\":null,\"dataOnlyDecision\":null,\"dataOnlyScore\":null,\"_type\":\"V4/Charge/Authenticate/AuthenticationResultExtensionThreedsV2\"},\"reason\":null,\"_type\":\"V4/Charge/Authenticate/AuthenticationResult\"},\"_type\":\"V4/AuthenticationResponseData\"},\"installmentNumber\":null,\"installmentCode\":null,\"markAuthorizationResponse\":{\"amount\":null,\"currency\":null,\"authorizationDate\":null,\"authorizationNumber\":null,\"authorizationResult\":null,\"_type\":\"V4/PaymentMethod/Details/Cards/MarkAuthorizationResponse\"},\"cardHolderName\":null,\"cardHolderPan\":\"497010XXXXXX0055\",\"cardHolderExpiryMonth\":12,\"cardHolderExpiryYear\":2025,\"identityDocumentNumber\":null,\"identityDocumentType\":null,\"initialIssuerTransactionIdentifier\":null,\"_type\":\"V4/PaymentMethod/Details/PaymentMethodDetails\"},\"acquirerDetails\":null,\"fraudManagement\":{\"riskControl\":[],\"riskAnalysis\":[],\"riskAssessments\":null,\"_type\":\"V4/PaymentMethod/Details/FraudManagement\"},\"subscriptionDetails\":{\"subscriptionId\":null,\"_type\":\"V4/PaymentMethod/Details/SubscriptionDetails\"},\"parentTransactionUuid\":null,\"mid\":\"9876357\",\"sequenceNumber\":1,\"taxAmount\":null,\"preTaxAmount\":null,\"taxRate\":null,\"externalTransactionId\":null,\"dcc\":null,\"nsu\":null,\"tid\":\"001\",\"acquirerNetwork\":\"CB\",\"taxRefundAmount\":null,\"userInfo\":\"JS Client\",\"paymentMethodTokenPreviouslyRegistered\":null,\"occurrenceType\":\"UNITAIRE\",\"archivalReferenceId\":\"L10294167201\",\"useCase\":null,\"wallet\":null,\"_type\":\"V4/TransactionDetails\"},\"_type\":\"V4/PaymentTransaction\"}],\"subMerchantDetails\":null,\"_type\":\"V4/Payment\"}", - "kr-answer-type": "V4/Payment", - "kr-hash": "b6f29417f8b1f10d860f3d9e18c9ddb31639ebd5a5b35fa52ef637dc904509e8" -} +{"kr-hash-key": "password", "kr-hash-algorithm": "sha256_hmac", "kr-answer": "{\"shopId\": \"69876357\", \"orderCycle\": \"CLOSED\", \"orderStatus\": \"PAID\", \"serverDate\": \"2024-04-11T08:31:08+00:00\", \"orderDetails\": {\"orderTotalAmount\": 12345, \"orderEffectiveAmount\": 12345, \"orderCurrency\": \"EUR\", \"mode\": \"TEST\", \"orderId\": \"514070fe-c12c-48b8-97cf-5262708673a3\", \"metadata\": null, \"_type\": \"V4/OrderDetails\"}, \"customer\": {\"billingDetails\": {\"address\": \"65368 Ward Plain\", \"category\": null, \"cellPhoneNumber\": null, \"city\": \"West Deborahland\", \"country\": \"SK\", \"district\": null, \"firstName\": \"Elizabeth\", \"identityCode\": null, \"identityType\": null, \"language\": \"FR\", \"lastName\": \"Brady\", \"phoneNumber\": null, \"state\": null, \"streetNumber\": null, \"title\": null, \"zipCode\": \"05597\", \"legalName\": null, \"_type\": \"V4/Customer/BillingDetails\"}, \"email\": \"john.doe@acme.org\", \"reference\": null, \"shippingDetails\": {\"address\": null, \"address2\": null, \"category\": null, \"city\": null, \"country\": null, \"deliveryCompanyName\": null, \"district\": null, \"firstName\": null, \"identityCode\": null, \"lastName\": null, \"legalName\": null, \"phoneNumber\": null, \"shippingMethod\": null, \"shippingSpeed\": null, \"state\": null, \"streetNumber\": null, \"zipCode\": null, \"_type\": \"V4/Customer/ShippingDetails\"}, \"extraDetails\": {\"browserAccept\": null, \"fingerPrintId\": null, \"ipAddress\": \"86.221.55.189\", \"browserUserAgent\": \"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0\", \"_type\": \"V4/Customer/ExtraDetails\"}, \"shoppingCart\": {\"insuranceAmount\": null, \"shippingAmount\": null, \"taxAmount\": null, \"cartItemInfo\": null, \"_type\": \"V4/Customer/ShoppingCart\"}, \"_type\": \"V4/Customer/Customer\"}, \"transactions\": [{\"shopId\": \"69876357\", \"uuid\": \"b4a819d9e4224247b58ccc861321a94a\", \"amount\": 12345, \"currency\": \"EUR\", \"paymentMethodType\": \"CARD\", \"paymentMethodToken\": null, \"status\": \"PAID\", \"detailedStatus\": \"AUTHORISED\", \"operationType\": \"DEBIT\", \"effectiveStrongAuthentication\": \"DISABLED\", \"creationDate\": \"2024-04-11T08:31:07+00:00\", \"errorCode\": null, \"errorMessage\": null, \"detailedErrorCode\": null, \"detailedErrorMessage\": null, \"metadata\": {\"installment_id\": \"d9356dd7-19a6-4695-b18e-ad93af41424a\"}, \"transactionDetails\": {\"liabilityShift\": \"NO\", \"effectiveAmount\": 12345, \"effectiveCurrency\": \"EUR\", \"creationContext\": \"CHARGE\", \"cardDetails\": {\"paymentSource\": \"EC\", \"manualValidation\": \"NO\", \"expectedCaptureDate\": \"2024-04-17T08:31:07+00:00\", \"effectiveBrand\": \"VISA\", \"pan\": \"497010XXXXXX0055\", \"expiryMonth\": 12, \"expiryYear\": 2025, \"country\": \"FR\", \"issuerCode\": null, \"issuerName\": \"Banque de demo et de l'innovation\", \"effectiveProductCode\": \"F\", \"legacyTransId\": \"941672\", \"legacyTransDate\": \"2024-04-11T08:31:07+00:00\", \"paymentMethodSource\": \"NEW\", \"authorizationResponse\": {\"amount\": 12345, \"currency\": \"EUR\", \"authorizationDate\": \"2024-04-11T08:31:07+00:00\", \"authorizationNumber\": \"3fe171\", \"authorizationResult\": \"0\", \"authorizationMode\": \"FULL\", \"_type\": \"V4/PaymentMethod/Details/Cards/CardAuthorizationResponse\"}, \"captureResponse\": {\"refundAmount\": null, \"refundCurrency\": null, \"captureDate\": null, \"captureFileNumber\": null, \"effectiveRefundAmount\": null, \"effectiveRefundCurrency\": null, \"_type\": \"V4/PaymentMethod/Details/Cards/CardCaptureResponse\"}, \"threeDSResponse\": {\"authenticationResultData\": {\"transactionCondition\": null, \"enrolled\": null, \"status\": null, \"eci\": null, \"xid\": null, \"cavvAlgorithm\": null, \"cavv\": null, \"signValid\": null, \"brand\": null, \"_type\": \"V4/PaymentMethod/Details/Cards/CardAuthenticationResponse\"}, \"_type\": \"V4/PaymentMethod/Details/Cards/ThreeDSResponse\"}, \"authenticationResponse\": {\"id\": \"aef3f5df-d4f8-4164-8853-b61db36ec52c\", \"operationSessionId\": null, \"protocol\": {\"name\": \"THREEDS\", \"version\": \"2.2.0\", \"network\": \"VISA\", \"challengePreference\": \"NO_PREFERENCE\", \"simulation\": true, \"_type\": \"V4/Charge/Authenticate/Protocol\"}, \"value\": {\"authenticationType\": null, \"authenticationId\": null, \"authenticationValue\": null, \"status\": \"NOT_ENROLLED\", \"commerceIndicator\": null, \"extension\": {\"authenticationType\": \"THREEDS_V2\", \"challengeCancelationIndicator\": null, \"cbScore\": null, \"cbAvalgo\": null, \"cbExemption\": null, \"paymentUseCase\": null, \"threeDSServerTransID\": null, \"dsTransID\": null, \"acsTransID\": null, \"sdkTransID\": null, \"transStatusReason\": null, \"requestedExemption\": \"TECHNICAL_ERROR\", \"requestorName\": \"Demo shop\", \"cardHolderInfo\": null, \"dataOnlyStatus\": null, \"dataOnlyDecision\": null, \"dataOnlyScore\": null, \"_type\": \"V4/Charge/Authenticate/AuthenticationResultExtensionThreedsV2\"}, \"reason\": null, \"_type\": \"V4/Charge/Authenticate/AuthenticationResult\"}, \"_type\": \"V4/AuthenticationResponseData\"}, \"installmentNumber\": null, \"installmentCode\": null, \"markAuthorizationResponse\": {\"amount\": null, \"currency\": null, \"authorizationDate\": null, \"authorizationNumber\": null, \"authorizationResult\": null, \"_type\": \"V4/PaymentMethod/Details/Cards/MarkAuthorizationResponse\"}, \"cardHolderName\": null, \"cardHolderPan\": \"497010XXXXXX0055\", \"cardHolderExpiryMonth\": 12, \"cardHolderExpiryYear\": 2025, \"identityDocumentNumber\": null, \"identityDocumentType\": null, \"initialIssuerTransactionIdentifier\": null, \"productCategory\": \"DEBIT\", \"nature\": \"CONSUMER_CARD\", \"_type\": \"V4/PaymentMethod/Details/CardDetails\"}, \"paymentMethodDetails\": {\"id\": \"497010XXXXXX0055\", \"paymentSource\": \"EC\", \"manualValidation\": \"NO\", \"expectedCaptureDate\": \"2024-04-17T08:31:07+00:00\", \"effectiveBrand\": \"VISA\", \"expiryMonth\": 12, \"expiryYear\": 2025, \"country\": \"FR\", \"issuerCode\": null, \"issuerName\": \"Banque de demo et de l'innovation\", \"effectiveProductCode\": \"F\", \"legacyTransId\": \"941672\", \"legacyTransDate\": \"2024-04-11T08:31:07+00:00\", \"paymentMethodSource\": \"NEW\", \"authorizationResponse\": {\"amount\": 12345, \"currency\": \"EUR\", \"authorizationDate\": \"2024-04-11T08:31:07+00:00\", \"authorizationNumber\": \"3fe171\", \"authorizationResult\": \"0\", \"authorizationMode\": \"FULL\", \"_type\": \"V4/PaymentMethod/Details/Cards/CardAuthorizationResponse\"}, \"captureResponse\": {\"refundAmount\": null, \"refundCurrency\": null, \"captureDate\": null, \"captureFileNumber\": null, \"effectiveRefundAmount\": null, \"effectiveRefundCurrency\": null, \"_type\": \"V4/PaymentMethod/Details/Cards/CardCaptureResponse\"}, \"authenticationResponse\": {\"id\": \"aef3f5df-d4f8-4164-8853-b61db36ec52c\", \"operationSessionId\": null, \"protocol\": {\"name\": \"THREEDS\", \"version\": \"2.2.0\", \"network\": \"VISA\", \"challengePreference\": \"NO_PREFERENCE\", \"simulation\": true, \"_type\": \"V4/Charge/Authenticate/Protocol\"}, \"value\": {\"authenticationType\": null, \"authenticationId\": null, \"authenticationValue\": null, \"status\": \"NOT_ENROLLED\", \"commerceIndicator\": null, \"extension\": {\"authenticationType\": \"THREEDS_V2\", \"challengeCancelationIndicator\": null, \"cbScore\": null, \"cbAvalgo\": null, \"cbExemption\": null, \"paymentUseCase\": null, \"threeDSServerTransID\": null, \"dsTransID\": null, \"acsTransID\": null, \"sdkTransID\": null, \"transStatusReason\": null, \"requestedExemption\": \"TECHNICAL_ERROR\", \"requestorName\": \"Demo shop\", \"cardHolderInfo\": null, \"dataOnlyStatus\": null, \"dataOnlyDecision\": null, \"dataOnlyScore\": null, \"_type\": \"V4/Charge/Authenticate/AuthenticationResultExtensionThreedsV2\"}, \"reason\": null, \"_type\": \"V4/Charge/Authenticate/AuthenticationResult\"}, \"_type\": \"V4/AuthenticationResponseData\"}, \"installmentNumber\": null, \"installmentCode\": null, \"markAuthorizationResponse\": {\"amount\": null, \"currency\": null, \"authorizationDate\": null, \"authorizationNumber\": null, \"authorizationResult\": null, \"_type\": \"V4/PaymentMethod/Details/Cards/MarkAuthorizationResponse\"}, \"cardHolderName\": null, \"cardHolderPan\": \"497010XXXXXX0055\", \"cardHolderExpiryMonth\": 12, \"cardHolderExpiryYear\": 2025, \"identityDocumentNumber\": null, \"identityDocumentType\": null, \"initialIssuerTransactionIdentifier\": null, \"_type\": \"V4/PaymentMethod/Details/PaymentMethodDetails\"}, \"acquirerDetails\": null, \"fraudManagement\": {\"riskControl\": [], \"riskAnalysis\": [], \"riskAssessments\": null, \"_type\": \"V4/PaymentMethod/Details/FraudManagement\"}, \"subscriptionDetails\": {\"subscriptionId\": null, \"_type\": \"V4/PaymentMethod/Details/SubscriptionDetails\"}, \"parentTransactionUuid\": null, \"mid\": \"9876357\", \"sequenceNumber\": 1, \"taxAmount\": null, \"preTaxAmount\": null, \"taxRate\": null, \"externalTransactionId\": null, \"dcc\": null, \"nsu\": null, \"tid\": \"001\", \"acquirerNetwork\": \"CB\", \"taxRefundAmount\": null, \"userInfo\": \"JS Client\", \"paymentMethodTokenPreviouslyRegistered\": null, \"occurrenceType\": \"UNITAIRE\", \"archivalReferenceId\": \"L10294167201\", \"useCase\": null, \"wallet\": null, \"_type\": \"V4/TransactionDetails\"}, \"_type\": \"V4/PaymentTransaction\"}], \"subMerchantDetails\": null, \"_type\": \"V4/Payment\"}", "kr-answer-type": "V4/Payment", "kr-hash": "b63f49ab335e4da005fbfd8e6acc2c13d6085348880c967fa6cf4d54058656b1"} \ No newline at end of file diff --git a/src/backend/joanie/tests/payment/lyra/requests/payment_refused.json b/src/backend/joanie/tests/payment/lyra/requests/payment_refused.json index 03236117a..f209b4a16 100644 --- a/src/backend/joanie/tests/payment/lyra/requests/payment_refused.json +++ b/src/backend/joanie/tests/payment/lyra/requests/payment_refused.json @@ -1,7 +1 @@ -{ - "kr-hash-key": "password", - "kr-hash-algorithm": "sha256_hmac", - "kr-answer": "{\"shopId\":\"69876357\",\"orderCycle\":\"OPEN\",\"orderStatus\":\"UNPAID\",\"serverDate\":\"2024-04-11T08:34:08+00:00\",\"orderDetails\":{\"orderTotalAmount\":12345,\"orderEffectiveAmount\":12345,\"orderCurrency\":\"EUR\",\"mode\":\"TEST\",\"orderId\":\"758c2570-a7af-4335-b091-340d0cc6e694\",\"metadata\":null,\"_type\":\"V4/OrderDetails\"},\"customer\":{\"billingDetails\":{\"address\":\"65368 Ward Plain\",\"category\":null,\"cellPhoneNumber\":null,\"city\":\"West Deborahland\",\"country\":\"SK\",\"district\":null,\"firstName\":\"Elizabeth\",\"identityCode\":null,\"identityType\":null,\"language\":\"FR\",\"lastName\":\"Brady\",\"phoneNumber\":null,\"state\":null,\"streetNumber\":null,\"title\":null,\"zipCode\":\"05597\",\"legalName\":null,\"_type\":\"V4/Customer/BillingDetails\"},\"email\":\"john.doe@acme.org\",\"reference\":null,\"shippingDetails\":{\"address\":null,\"address2\":null,\"category\":null,\"city\":null,\"country\":null,\"deliveryCompanyName\":null,\"district\":null,\"firstName\":null,\"identityCode\":null,\"lastName\":null,\"legalName\":null,\"phoneNumber\":null,\"shippingMethod\":null,\"shippingSpeed\":null,\"state\":null,\"streetNumber\":null,\"zipCode\":null,\"_type\":\"V4/Customer/ShippingDetails\"},\"extraDetails\":{\"browserAccept\":null,\"fingerPrintId\":null,\"ipAddress\":\"86.221.55.189\",\"browserUserAgent\":\"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0\",\"_type\":\"V4/Customer/ExtraDetails\"},\"shoppingCart\":{\"insuranceAmount\":null,\"shippingAmount\":null,\"taxAmount\":null,\"cartItemInfo\":null,\"_type\":\"V4/Customer/ShoppingCart\"},\"_type\":\"V4/Customer/Customer\"},\"transactions\":[{\"shopId\":\"69876357\",\"uuid\":\"720324c7b1b1453d8e5463a9705e47e9\",\"amount\":12345,\"currency\":\"EUR\",\"paymentMethodType\":\"CARD\",\"paymentMethodToken\":null,\"status\":\"UNPAID\",\"detailedStatus\":\"REFUSED\",\"operationType\":\"DEBIT\",\"effectiveStrongAuthentication\":\"DISABLED\",\"creationDate\":\"2024-04-11T08:34:08+00:00\",\"errorCode\":\"ACQ_001\",\"errorMessage\":\"payment refused\",\"detailedErrorCode\":\"51\",\"detailedErrorMessage\":\"Insufficient funds or credit limit exceeded\",\"metadata\":null,\"transactionDetails\":{\"liabilityShift\":null,\"effectiveAmount\":12345,\"effectiveCurrency\":\"EUR\",\"creationContext\":\"CHARGE\",\"cardDetails\":{\"paymentSource\":\"EC\",\"manualValidation\":\"NO\",\"expectedCaptureDate\":\"2024-04-17T08:34:08+00:00\",\"effectiveBrand\":\"VISA\",\"pan\":\"497010XXXXXX0113\",\"expiryMonth\":12,\"expiryYear\":2025,\"country\":\"FR\",\"issuerCode\":null,\"issuerName\":\"Banque de demo et de l'innovation\",\"effectiveProductCode\":null,\"legacyTransId\":\"904877\",\"legacyTransDate\":\"2024-04-11T08:34:08+00:00\",\"paymentMethodSource\":\"NEW\",\"authorizationResponse\":{\"amount\":12345,\"currency\":\"EUR\",\"authorizationDate\":\"2024-04-11T08:34:08+00:00\",\"authorizationNumber\":null,\"authorizationResult\":\"51\",\"authorizationMode\":\"FULL\",\"_type\":\"V4/PaymentMethod/Details/Cards/CardAuthorizationResponse\"},\"captureResponse\":{\"refundAmount\":null,\"refundCurrency\":null,\"captureDate\":null,\"captureFileNumber\":null,\"effectiveRefundAmount\":null,\"effectiveRefundCurrency\":null,\"_type\":\"V4/PaymentMethod/Details/Cards/CardCaptureResponse\"},\"threeDSResponse\":{\"authenticationResultData\":{\"transactionCondition\":null,\"enrolled\":null,\"status\":null,\"eci\":null,\"xid\":null,\"cavvAlgorithm\":null,\"cavv\":null,\"signValid\":null,\"brand\":null,\"_type\":\"V4/PaymentMethod/Details/Cards/CardAuthenticationResponse\"},\"_type\":\"V4/PaymentMethod/Details/Cards/ThreeDSResponse\"},\"authenticationResponse\":{\"id\":\"eea5687c-54c2-490a-9fdd-d17ab7e52c56\",\"operationSessionId\":null,\"protocol\":{\"name\":\"THREEDS\",\"version\":\"2.2.0\",\"network\":\"VISA\",\"challengePreference\":\"NO_PREFERENCE\",\"simulation\":true,\"_type\":\"V4/Charge/Authenticate/Protocol\"},\"value\":{\"authenticationType\":null,\"authenticationId\":null,\"authenticationValue\":null,\"status\":\"NOT_ENROLLED\",\"commerceIndicator\":null,\"extension\":{\"authenticationType\":\"THREEDS_V2\",\"challengeCancelationIndicator\":null,\"cbScore\":null,\"cbAvalgo\":null,\"cbExemption\":null,\"paymentUseCase\":null,\"threeDSServerTransID\":null,\"dsTransID\":null,\"acsTransID\":null,\"sdkTransID\":null,\"transStatusReason\":null,\"requestedExemption\":\"TECHNICAL_ERROR\",\"requestorName\":\"Demo shop\",\"cardHolderInfo\":null,\"dataOnlyStatus\":null,\"dataOnlyDecision\":null,\"dataOnlyScore\":null,\"_type\":\"V4/Charge/Authenticate/AuthenticationResultExtensionThreedsV2\"},\"reason\":null,\"_type\":\"V4/Charge/Authenticate/AuthenticationResult\"},\"_type\":\"V4/AuthenticationResponseData\"},\"installmentNumber\":null,\"installmentCode\":null,\"markAuthorizationResponse\":{\"amount\":null,\"currency\":null,\"authorizationDate\":null,\"authorizationNumber\":null,\"authorizationResult\":null,\"_type\":\"V4/PaymentMethod/Details/Cards/MarkAuthorizationResponse\"},\"cardHolderName\":null,\"cardHolderPan\":\"497010XXXXXX0113\",\"cardHolderExpiryMonth\":12,\"cardHolderExpiryYear\":2025,\"identityDocumentNumber\":null,\"identityDocumentType\":null,\"initialIssuerTransactionIdentifier\":null,\"productCategory\":\"DEBIT\",\"nature\":\"CONSUMER_CARD\",\"_type\":\"V4/PaymentMethod/Details/CardDetails\"},\"paymentMethodDetails\":{\"id\":\"497010XXXXXX0113\",\"paymentSource\":\"EC\",\"manualValidation\":\"NO\",\"expectedCaptureDate\":\"2024-04-17T08:34:08+00:00\",\"effectiveBrand\":\"VISA\",\"expiryMonth\":12,\"expiryYear\":2025,\"country\":\"FR\",\"issuerCode\":null,\"issuerName\":\"Banque de demo et de l'innovation\",\"effectiveProductCode\":null,\"legacyTransId\":\"904877\",\"legacyTransDate\":\"2024-04-11T08:34:08+00:00\",\"paymentMethodSource\":\"NEW\",\"authorizationResponse\":{\"amount\":12345,\"currency\":\"EUR\",\"authorizationDate\":\"2024-04-11T08:34:08+00:00\",\"authorizationNumber\":null,\"authorizationResult\":\"51\",\"authorizationMode\":\"FULL\",\"_type\":\"V4/PaymentMethod/Details/Cards/CardAuthorizationResponse\"},\"captureResponse\":{\"refundAmount\":null,\"refundCurrency\":null,\"captureDate\":null,\"captureFileNumber\":null,\"effectiveRefundAmount\":null,\"effectiveRefundCurrency\":null,\"_type\":\"V4/PaymentMethod/Details/Cards/CardCaptureResponse\"},\"authenticationResponse\":{\"id\":\"eea5687c-54c2-490a-9fdd-d17ab7e52c56\",\"operationSessionId\":null,\"protocol\":{\"name\":\"THREEDS\",\"version\":\"2.2.0\",\"network\":\"VISA\",\"challengePreference\":\"NO_PREFERENCE\",\"simulation\":true,\"_type\":\"V4/Charge/Authenticate/Protocol\"},\"value\":{\"authenticationType\":null,\"authenticationId\":null,\"authenticationValue\":null,\"status\":\"NOT_ENROLLED\",\"commerceIndicator\":null,\"extension\":{\"authenticationType\":\"THREEDS_V2\",\"challengeCancelationIndicator\":null,\"cbScore\":null,\"cbAvalgo\":null,\"cbExemption\":null,\"paymentUseCase\":null,\"threeDSServerTransID\":null,\"dsTransID\":null,\"acsTransID\":null,\"sdkTransID\":null,\"transStatusReason\":null,\"requestedExemption\":\"TECHNICAL_ERROR\",\"requestorName\":\"Demo shop\",\"cardHolderInfo\":null,\"dataOnlyStatus\":null,\"dataOnlyDecision\":null,\"dataOnlyScore\":null,\"_type\":\"V4/Charge/Authenticate/AuthenticationResultExtensionThreedsV2\"},\"reason\":null,\"_type\":\"V4/Charge/Authenticate/AuthenticationResult\"},\"_type\":\"V4/AuthenticationResponseData\"},\"installmentNumber\":null,\"installmentCode\":null,\"markAuthorizationResponse\":{\"amount\":null,\"currency\":null,\"authorizationDate\":null,\"authorizationNumber\":null,\"authorizationResult\":null,\"_type\":\"V4/PaymentMethod/Details/Cards/MarkAuthorizationResponse\"},\"cardHolderName\":null,\"cardHolderPan\":\"497010XXXXXX0113\",\"cardHolderExpiryMonth\":12,\"cardHolderExpiryYear\":2025,\"identityDocumentNumber\":null,\"identityDocumentType\":null,\"initialIssuerTransactionIdentifier\":null,\"_type\":\"V4/PaymentMethod/Details/PaymentMethodDetails\"},\"acquirerDetails\":null,\"fraudManagement\":{\"riskControl\":[],\"riskAnalysis\":[],\"riskAssessments\":null,\"_type\":\"V4/PaymentMethod/Details/FraudManagement\"},\"subscriptionDetails\":{\"subscriptionId\":null,\"_type\":\"V4/PaymentMethod/Details/SubscriptionDetails\"},\"parentTransactionUuid\":null,\"mid\":\"9876357\",\"sequenceNumber\":1,\"taxAmount\":null,\"preTaxAmount\":null,\"taxRate\":null,\"externalTransactionId\":null,\"dcc\":null,\"nsu\":null,\"tid\":\"001\",\"acquirerNetwork\":\"CB\",\"taxRefundAmount\":null,\"userInfo\":\"JS Client\",\"paymentMethodTokenPreviouslyRegistered\":null,\"occurrenceType\":\"UNITAIRE\",\"archivalReferenceId\":\"L10290487701\",\"useCase\":null,\"wallet\":null,\"_type\":\"V4/TransactionDetails\"},\"_type\":\"V4/PaymentTransaction\"}],\"subMerchantDetails\":null,\"_type\":\"V4/Payment\"}", - "kr-answer-type": "V4/Payment", - "kr-hash": "66c212275bc766eedccd7d09da8df498944ae4d6cf24802418837458bc30ce5c" -} +{"kr-hash-key": "password", "kr-hash-algorithm": "sha256_hmac", "kr-answer": "{\"shopId\": \"69876357\", \"orderCycle\": \"OPEN\", \"orderStatus\": \"UNPAID\", \"serverDate\": \"2024-04-11T08:34:08+00:00\", \"orderDetails\": {\"orderTotalAmount\": 12345, \"orderEffectiveAmount\": 12345, \"orderCurrency\": \"EUR\", \"mode\": \"TEST\", \"orderId\": \"758c2570-a7af-4335-b091-340d0cc6e694\", \"metadata\": null, \"_type\": \"V4/OrderDetails\"}, \"customer\": {\"billingDetails\": {\"address\": \"65368 Ward Plain\", \"category\": null, \"cellPhoneNumber\": null, \"city\": \"West Deborahland\", \"country\": \"SK\", \"district\": null, \"firstName\": \"Elizabeth\", \"identityCode\": null, \"identityType\": null, \"language\": \"FR\", \"lastName\": \"Brady\", \"phoneNumber\": null, \"state\": null, \"streetNumber\": null, \"title\": null, \"zipCode\": \"05597\", \"legalName\": null, \"_type\": \"V4/Customer/BillingDetails\"}, \"email\": \"john.doe@acme.org\", \"reference\": null, \"shippingDetails\": {\"address\": null, \"address2\": null, \"category\": null, \"city\": null, \"country\": null, \"deliveryCompanyName\": null, \"district\": null, \"firstName\": null, \"identityCode\": null, \"lastName\": null, \"legalName\": null, \"phoneNumber\": null, \"shippingMethod\": null, \"shippingSpeed\": null, \"state\": null, \"streetNumber\": null, \"zipCode\": null, \"_type\": \"V4/Customer/ShippingDetails\"}, \"extraDetails\": {\"browserAccept\": null, \"fingerPrintId\": null, \"ipAddress\": \"86.221.55.189\", \"browserUserAgent\": \"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0\", \"_type\": \"V4/Customer/ExtraDetails\"}, \"shoppingCart\": {\"insuranceAmount\": null, \"shippingAmount\": null, \"taxAmount\": null, \"cartItemInfo\": null, \"_type\": \"V4/Customer/ShoppingCart\"}, \"_type\": \"V4/Customer/Customer\"}, \"transactions\": [{\"shopId\": \"69876357\", \"uuid\": \"720324c7b1b1453d8e5463a9705e47e9\", \"amount\": 12345, \"currency\": \"EUR\", \"paymentMethodType\": \"CARD\", \"paymentMethodToken\": null, \"status\": \"UNPAID\", \"detailedStatus\": \"REFUSED\", \"operationType\": \"DEBIT\", \"effectiveStrongAuthentication\": \"DISABLED\", \"creationDate\": \"2024-04-11T08:34:08+00:00\", \"errorCode\": \"ACQ_001\", \"errorMessage\": \"payment refused\", \"detailedErrorCode\": \"51\", \"detailedErrorMessage\": \"Insufficient funds or credit limit exceeded\", \"metadata\": {\"installment_id\": \"d9356dd7-19a6-4695-b18e-ad93af41424a\"}, \"transactionDetails\": {\"liabilityShift\": null, \"effectiveAmount\": 12345, \"effectiveCurrency\": \"EUR\", \"creationContext\": \"CHARGE\", \"cardDetails\": {\"paymentSource\": \"EC\", \"manualValidation\": \"NO\", \"expectedCaptureDate\": \"2024-04-17T08:34:08+00:00\", \"effectiveBrand\": \"VISA\", \"pan\": \"497010XXXXXX0113\", \"expiryMonth\": 12, \"expiryYear\": 2025, \"country\": \"FR\", \"issuerCode\": null, \"issuerName\": \"Banque de demo et de l'innovation\", \"effectiveProductCode\": null, \"legacyTransId\": \"904877\", \"legacyTransDate\": \"2024-04-11T08:34:08+00:00\", \"paymentMethodSource\": \"NEW\", \"authorizationResponse\": {\"amount\": 12345, \"currency\": \"EUR\", \"authorizationDate\": \"2024-04-11T08:34:08+00:00\", \"authorizationNumber\": null, \"authorizationResult\": \"51\", \"authorizationMode\": \"FULL\", \"_type\": \"V4/PaymentMethod/Details/Cards/CardAuthorizationResponse\"}, \"captureResponse\": {\"refundAmount\": null, \"refundCurrency\": null, \"captureDate\": null, \"captureFileNumber\": null, \"effectiveRefundAmount\": null, \"effectiveRefundCurrency\": null, \"_type\": \"V4/PaymentMethod/Details/Cards/CardCaptureResponse\"}, \"threeDSResponse\": {\"authenticationResultData\": {\"transactionCondition\": null, \"enrolled\": null, \"status\": null, \"eci\": null, \"xid\": null, \"cavvAlgorithm\": null, \"cavv\": null, \"signValid\": null, \"brand\": null, \"_type\": \"V4/PaymentMethod/Details/Cards/CardAuthenticationResponse\"}, \"_type\": \"V4/PaymentMethod/Details/Cards/ThreeDSResponse\"}, \"authenticationResponse\": {\"id\": \"eea5687c-54c2-490a-9fdd-d17ab7e52c56\", \"operationSessionId\": null, \"protocol\": {\"name\": \"THREEDS\", \"version\": \"2.2.0\", \"network\": \"VISA\", \"challengePreference\": \"NO_PREFERENCE\", \"simulation\": true, \"_type\": \"V4/Charge/Authenticate/Protocol\"}, \"value\": {\"authenticationType\": null, \"authenticationId\": null, \"authenticationValue\": null, \"status\": \"NOT_ENROLLED\", \"commerceIndicator\": null, \"extension\": {\"authenticationType\": \"THREEDS_V2\", \"challengeCancelationIndicator\": null, \"cbScore\": null, \"cbAvalgo\": null, \"cbExemption\": null, \"paymentUseCase\": null, \"threeDSServerTransID\": null, \"dsTransID\": null, \"acsTransID\": null, \"sdkTransID\": null, \"transStatusReason\": null, \"requestedExemption\": \"TECHNICAL_ERROR\", \"requestorName\": \"Demo shop\", \"cardHolderInfo\": null, \"dataOnlyStatus\": null, \"dataOnlyDecision\": null, \"dataOnlyScore\": null, \"_type\": \"V4/Charge/Authenticate/AuthenticationResultExtensionThreedsV2\"}, \"reason\": null, \"_type\": \"V4/Charge/Authenticate/AuthenticationResult\"}, \"_type\": \"V4/AuthenticationResponseData\"}, \"installmentNumber\": null, \"installmentCode\": null, \"markAuthorizationResponse\": {\"amount\": null, \"currency\": null, \"authorizationDate\": null, \"authorizationNumber\": null, \"authorizationResult\": null, \"_type\": \"V4/PaymentMethod/Details/Cards/MarkAuthorizationResponse\"}, \"cardHolderName\": null, \"cardHolderPan\": \"497010XXXXXX0113\", \"cardHolderExpiryMonth\": 12, \"cardHolderExpiryYear\": 2025, \"identityDocumentNumber\": null, \"identityDocumentType\": null, \"initialIssuerTransactionIdentifier\": null, \"productCategory\": \"DEBIT\", \"nature\": \"CONSUMER_CARD\", \"_type\": \"V4/PaymentMethod/Details/CardDetails\"}, \"paymentMethodDetails\": {\"id\": \"497010XXXXXX0113\", \"paymentSource\": \"EC\", \"manualValidation\": \"NO\", \"expectedCaptureDate\": \"2024-04-17T08:34:08+00:00\", \"effectiveBrand\": \"VISA\", \"expiryMonth\": 12, \"expiryYear\": 2025, \"country\": \"FR\", \"issuerCode\": null, \"issuerName\": \"Banque de demo et de l'innovation\", \"effectiveProductCode\": null, \"legacyTransId\": \"904877\", \"legacyTransDate\": \"2024-04-11T08:34:08+00:00\", \"paymentMethodSource\": \"NEW\", \"authorizationResponse\": {\"amount\": 12345, \"currency\": \"EUR\", \"authorizationDate\": \"2024-04-11T08:34:08+00:00\", \"authorizationNumber\": null, \"authorizationResult\": \"51\", \"authorizationMode\": \"FULL\", \"_type\": \"V4/PaymentMethod/Details/Cards/CardAuthorizationResponse\"}, \"captureResponse\": {\"refundAmount\": null, \"refundCurrency\": null, \"captureDate\": null, \"captureFileNumber\": null, \"effectiveRefundAmount\": null, \"effectiveRefundCurrency\": null, \"_type\": \"V4/PaymentMethod/Details/Cards/CardCaptureResponse\"}, \"authenticationResponse\": {\"id\": \"eea5687c-54c2-490a-9fdd-d17ab7e52c56\", \"operationSessionId\": null, \"protocol\": {\"name\": \"THREEDS\", \"version\": \"2.2.0\", \"network\": \"VISA\", \"challengePreference\": \"NO_PREFERENCE\", \"simulation\": true, \"_type\": \"V4/Charge/Authenticate/Protocol\"}, \"value\": {\"authenticationType\": null, \"authenticationId\": null, \"authenticationValue\": null, \"status\": \"NOT_ENROLLED\", \"commerceIndicator\": null, \"extension\": {\"authenticationType\": \"THREEDS_V2\", \"challengeCancelationIndicator\": null, \"cbScore\": null, \"cbAvalgo\": null, \"cbExemption\": null, \"paymentUseCase\": null, \"threeDSServerTransID\": null, \"dsTransID\": null, \"acsTransID\": null, \"sdkTransID\": null, \"transStatusReason\": null, \"requestedExemption\": \"TECHNICAL_ERROR\", \"requestorName\": \"Demo shop\", \"cardHolderInfo\": null, \"dataOnlyStatus\": null, \"dataOnlyDecision\": null, \"dataOnlyScore\": null, \"_type\": \"V4/Charge/Authenticate/AuthenticationResultExtensionThreedsV2\"}, \"reason\": null, \"_type\": \"V4/Charge/Authenticate/AuthenticationResult\"}, \"_type\": \"V4/AuthenticationResponseData\"}, \"installmentNumber\": null, \"installmentCode\": null, \"markAuthorizationResponse\": {\"amount\": null, \"currency\": null, \"authorizationDate\": null, \"authorizationNumber\": null, \"authorizationResult\": null, \"_type\": \"V4/PaymentMethod/Details/Cards/MarkAuthorizationResponse\"}, \"cardHolderName\": null, \"cardHolderPan\": \"497010XXXXXX0113\", \"cardHolderExpiryMonth\": 12, \"cardHolderExpiryYear\": 2025, \"identityDocumentNumber\": null, \"identityDocumentType\": null, \"initialIssuerTransactionIdentifier\": null, \"_type\": \"V4/PaymentMethod/Details/PaymentMethodDetails\"}, \"acquirerDetails\": null, \"fraudManagement\": {\"riskControl\": [], \"riskAnalysis\": [], \"riskAssessments\": null, \"_type\": \"V4/PaymentMethod/Details/FraudManagement\"}, \"subscriptionDetails\": {\"subscriptionId\": null, \"_type\": \"V4/PaymentMethod/Details/SubscriptionDetails\"}, \"parentTransactionUuid\": null, \"mid\": \"9876357\", \"sequenceNumber\": 1, \"taxAmount\": null, \"preTaxAmount\": null, \"taxRate\": null, \"externalTransactionId\": null, \"dcc\": null, \"nsu\": null, \"tid\": \"001\", \"acquirerNetwork\": \"CB\", \"taxRefundAmount\": null, \"userInfo\": \"JS Client\", \"paymentMethodTokenPreviouslyRegistered\": null, \"occurrenceType\": \"UNITAIRE\", \"archivalReferenceId\": \"L10290487701\", \"useCase\": null, \"wallet\": null, \"_type\": \"V4/TransactionDetails\"}, \"_type\": \"V4/PaymentTransaction\"}], \"subMerchantDetails\": null, \"_type\": \"V4/Payment\"}", "kr-answer-type": "V4/Payment", "kr-hash": "f2c66a7da845b5885125c37d5c4fe88c01f60ac1acafab092694df29bc5d8a42"} \ No newline at end of file diff --git a/src/backend/joanie/tests/payment/lyra/requests/tokenize_card_for_user.json b/src/backend/joanie/tests/payment/lyra/requests/tokenize_card_for_user.json new file mode 100644 index 000000000..2b8b6785b --- /dev/null +++ b/src/backend/joanie/tests/payment/lyra/requests/tokenize_card_for_user.json @@ -0,0 +1,7 @@ +{ + "kr-hash-key": "password", + "kr-hash-algorithm": "sha256_hmac", + "kr-answer": "{\"shopId\":\"79264058\",\"orderCycle\":\"CLOSED\",\"orderStatus\":\"PAID\",\"serverDate\":\"2024-06-27T14:52:47+00:00\",\"orderDetails\":{\"orderTotalAmount\":0,\"orderEffectiveAmount\":0,\"orderCurrency\":\"EUR\",\"mode\":\"TEST\",\"orderId\":null,\"metadata\":null,\"_type\":\"V4/OrderDetails\"},\"customer\":{\"billingDetails\":{\"address\":null,\"category\":null,\"cellPhoneNumber\":null,\"city\":null,\"country\":null,\"district\":null,\"firstName\":null,\"identityCode\":null,\"identityType\":null,\"language\":\"EN\",\"lastName\":null,\"phoneNumber\":null,\"state\":null,\"streetNumber\":null,\"title\":null,\"zipCode\":null,\"legalName\":null,\"_type\":\"V4/Customer/BillingDetails\"},\"email\":\"john.doe@acme.org\",\"reference\":\"0a920c52-7ecc-47b3-83f5-127b846ac79c\",\"shippingDetails\":{\"address\":null,\"address2\":null,\"category\":null,\"city\":null,\"country\":null,\"deliveryCompanyName\":null,\"district\":null,\"firstName\":null,\"identityCode\":null,\"lastName\":null,\"legalName\":null,\"phoneNumber\":null,\"shippingMethod\":null,\"shippingSpeed\":null,\"state\":null,\"streetNumber\":null,\"zipCode\":null,\"_type\":\"V4/Customer/ShippingDetails\"},\"extraDetails\":{\"browserAccept\":null,\"fingerPrintId\":null,\"ipAddress\":\"51.75.249.201\",\"browserUserAgent\":\"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0\",\"_type\":\"V4/Customer/ExtraDetails\"},\"shoppingCart\":{\"insuranceAmount\":null,\"shippingAmount\":null,\"taxAmount\":null,\"cartItemInfo\":null,\"_type\":\"V4/Customer/ShoppingCart\"},\"_type\":\"V4/Customer/Customer\"},\"transactions\":[{\"shopId\":\"79264058\",\"uuid\":\"622cf59b8ac5495ea67a937addc3060c\",\"amount\":0,\"currency\":\"EUR\",\"paymentMethodType\":\"CARD\",\"paymentMethodToken\":\"cedab61905974afe9794c87085543dba\",\"status\":\"PAID\",\"detailedStatus\":\"ACCEPTED\",\"operationType\":\"VERIFICATION\",\"effectiveStrongAuthentication\":\"ENABLED\",\"creationDate\":\"2024-06-27T14:52:46+00:00\",\"errorCode\":null,\"errorMessage\":null,\"detailedErrorCode\":null,\"detailedErrorMessage\":null,\"metadata\":null,\"transactionDetails\":{\"liabilityShift\":\"NO\",\"effectiveAmount\":0,\"effectiveCurrency\":\"EUR\",\"creationContext\":\"VERIFICATION\",\"cardDetails\":{\"paymentSource\":\"EC\",\"manualValidation\":\"NO\",\"expectedCaptureDate\":null,\"effectiveBrand\":\"VISA\",\"pan\":\"497011XXXXXX1003\",\"expiryMonth\":12,\"expiryYear\":2025,\"country\":\"FR\",\"issuerCode\":17807,\"issuerName\":\"Banque Populaire Occitane\",\"effectiveProductCode\":null,\"legacyTransId\":\"9g1h4e\",\"legacyTransDate\":\"2024-06-27T14:52:46+00:00\",\"paymentMethodSource\":\"TOKEN\",\"authorizationResponse\":{\"amount\":null,\"currency\":null,\"authorizationDate\":null,\"authorizationNumber\":null,\"authorizationResult\":null,\"authorizationMode\":\"MARK\",\"_type\":\"V4/PaymentMethod/Details/Cards/CardAuthorizationResponse\"},\"captureResponse\":{\"refundAmount\":null,\"refundCurrency\":null,\"captureDate\":null,\"captureFileNumber\":null,\"effectiveRefundAmount\":null,\"effectiveRefundCurrency\":null,\"_type\":\"V4/PaymentMethod/Details/Cards/CardCaptureResponse\"},\"threeDSResponse\":{\"authenticationResultData\":{\"transactionCondition\":null,\"enrolled\":null,\"status\":null,\"eci\":null,\"xid\":null,\"cavvAlgorithm\":null,\"cavv\":null,\"signValid\":null,\"brand\":null,\"_type\":\"V4/PaymentMethod/Details/Cards/CardAuthenticationResponse\"},\"_type\":\"V4/PaymentMethod/Details/Cards/ThreeDSResponse\"},\"authenticationResponse\":{\"id\":\"3f198c4d-de23-49bc-9b9e-35a94cbc89c4\",\"operationSessionId\":null,\"protocol\":{\"name\":\"THREEDS\",\"version\":\"2.1.0\",\"network\":\"VISA\",\"challengePreference\":\"CHALLENGE_MANDATED\",\"simulation\":true,\"_type\":\"V4/Charge/Authenticate/Protocol\"},\"value\":{\"authenticationType\":\"CHALLENGE\",\"authenticationId\":{\"authenticationIdType\":\"dsTransId\",\"value\":\"5dab1964-ad95-4715-86c0-32557e6f5b46\",\"_type\":\"V4/Charge/Authenticate/AuthenticationId\"},\"authenticationValue\":{\"authenticationValueType\":\"CAVV\",\"value\":\"t**************************=\",\"_type\":\"V4/Charge/Authenticate/AuthenticationValue\"},\"status\":\"SUCCESS\",\"commerceIndicator\":\"05\",\"extension\":{\"authenticationType\":\"THREEDS_V2\",\"challengeCancelationIndicator\":null,\"cbScore\":null,\"cbAvalgo\":null,\"cbExemption\":null,\"paymentUseCase\":null,\"threeDSServerTransID\":\"3f198c4d-de23-49bc-9b9e-35a94cbc89c4\",\"dsTransID\":\"5dab1964-ad95-4715-86c0-32557e6f5b46\",\"acsTransID\":\"d72df9dc-893d-4984-98ac-500a842227fd\",\"sdkTransID\":null,\"transStatusReason\":null,\"requestedExemption\":null,\"requestorName\":\"FUN MOOC\",\"cardHolderInfo\":null,\"dataOnlyStatus\":null,\"dataOnlyDecision\":null,\"dataOnlyScore\":null,\"_type\":\"V4/Charge/Authenticate/AuthenticationResultExtensionThreedsV2\"},\"reason\":{\"code\":null,\"message\":null,\"_type\":\"V4/Charge/Authenticate/AuthenticationResultReason\"},\"_type\":\"V4/Charge/Authenticate/AuthenticationResult\"},\"_type\":\"V4/AuthenticationResponseData\"},\"installmentNumber\":null,\"installmentCode\":null,\"markAuthorizationResponse\":{\"amount\":0,\"currency\":\"EUR\",\"authorizationDate\":\"2024-06-27T14:52:46+00:00\",\"authorizationNumber\":\"3fefad\",\"authorizationResult\":\"0\",\"_type\":\"V4/PaymentMethod/Details/Cards/MarkAuthorizationResponse\"},\"cardHolderName\":null,\"cardHolderPan\":\"497011XXXXXX1003\",\"cardHolderExpiryMonth\":12,\"cardHolderExpiryYear\":2025,\"identityDocumentNumber\":null,\"identityDocumentType\":null,\"initialIssuerTransactionIdentifier\":\"1873524233492261\",\"productCategory\":\"DEBIT\",\"nature\":\"CONSUMER_CARD\",\"_type\":\"V4/PaymentMethod/Details/CardDetails\"},\"paymentMethodDetails\":{\"id\":\"497011XXXXXX1003\",\"paymentSource\":\"EC\",\"manualValidation\":\"NO\",\"expectedCaptureDate\":null,\"effectiveBrand\":\"VISA\",\"expiryMonth\":12,\"expiryYear\":2025,\"country\":\"FR\",\"issuerCode\":17807,\"issuerName\":\"Banque Populaire Occitane\",\"effectiveProductCode\":null,\"legacyTransId\":\"9g1h4e\",\"legacyTransDate\":\"2024-06-27T14:52:46+00:00\",\"paymentMethodSource\":\"TOKEN\",\"authorizationResponse\":{\"amount\":null,\"currency\":null,\"authorizationDate\":null,\"authorizationNumber\":null,\"authorizationResult\":null,\"authorizationMode\":\"MARK\",\"_type\":\"V4/PaymentMethod/Details/Cards/CardAuthorizationResponse\"},\"captureResponse\":{\"refundAmount\":null,\"refundCurrency\":null,\"captureDate\":null,\"captureFileNumber\":null,\"effectiveRefundAmount\":null,\"effectiveRefundCurrency\":null,\"_type\":\"V4/PaymentMethod/Details/Cards/CardCaptureResponse\"},\"authenticationResponse\":{\"id\":\"3f198c4d-de23-49bc-9b9e-35a94cbc89c4\",\"operationSessionId\":null,\"protocol\":{\"name\":\"THREEDS\",\"version\":\"2.1.0\",\"network\":\"VISA\",\"challengePreference\":\"CHALLENGE_MANDATED\",\"simulation\":true,\"_type\":\"V4/Charge/Authenticate/Protocol\"},\"value\":{\"authenticationType\":\"CHALLENGE\",\"authenticationId\":{\"authenticationIdType\":\"dsTransId\",\"value\":\"5dab1964-ad95-4715-86c0-32557e6f5b46\",\"_type\":\"V4/Charge/Authenticate/AuthenticationId\"},\"authenticationValue\":{\"authenticationValueType\":\"CAVV\",\"value\":\"t**************************=\",\"_type\":\"V4/Charge/Authenticate/AuthenticationValue\"},\"status\":\"SUCCESS\",\"commerceIndicator\":\"05\",\"extension\":{\"authenticationType\":\"THREEDS_V2\",\"challengeCancelationIndicator\":null,\"cbScore\":null,\"cbAvalgo\":null,\"cbExemption\":null,\"paymentUseCase\":null,\"threeDSServerTransID\":\"3f198c4d-de23-49bc-9b9e-35a94cbc89c4\",\"dsTransID\":\"5dab1964-ad95-4715-86c0-32557e6f5b46\",\"acsTransID\":\"d72df9dc-893d-4984-98ac-500a842227fd\",\"sdkTransID\":null,\"transStatusReason\":null,\"requestedExemption\":null,\"requestorName\":\"FUN MOOC\",\"cardHolderInfo\":null,\"dataOnlyStatus\":null,\"dataOnlyDecision\":null,\"dataOnlyScore\":null,\"_type\":\"V4/Charge/Authenticate/AuthenticationResultExtensionThreedsV2\"},\"reason\":{\"code\":null,\"message\":null,\"_type\":\"V4/Charge/Authenticate/AuthenticationResultReason\"},\"_type\":\"V4/Charge/Authenticate/AuthenticationResult\"},\"_type\":\"V4/AuthenticationResponseData\"},\"installmentNumber\":null,\"installmentCode\":null,\"markAuthorizationResponse\":{\"amount\":0,\"currency\":\"EUR\",\"authorizationDate\":\"2024-06-27T14:52:46+00:00\",\"authorizationNumber\":\"3fefad\",\"authorizationResult\":\"0\",\"_type\":\"V4/PaymentMethod/Details/Cards/MarkAuthorizationResponse\"},\"cardHolderName\":null,\"cardHolderPan\":\"497011XXXXXX1003\",\"cardHolderExpiryMonth\":12,\"cardHolderExpiryYear\":2025,\"identityDocumentNumber\":null,\"identityDocumentType\":null,\"initialIssuerTransactionIdentifier\":\"1873524233492261\",\"_type\":\"V4/PaymentMethod/Details/PaymentMethodDetails\"},\"acquirerDetails\":null,\"fraudManagement\":{\"riskControl\":[],\"riskAnalysis\":[],\"riskAssessments\":null,\"_type\":\"V4/PaymentMethod/Details/FraudManagement\"},\"subscriptionDetails\":{\"subscriptionId\":null,\"_type\":\"V4/PaymentMethod/Details/SubscriptionDetails\"},\"parentTransactionUuid\":null,\"mid\":\"2357367\",\"sequenceNumber\":1,\"taxAmount\":null,\"preTaxAmount\":null,\"taxRate\":null,\"externalTransactionId\":null,\"dcc\":null,\"nsu\":null,\"tid\":\"001\",\"acquirerNetwork\":\"CB\",\"taxRefundAmount\":null,\"userInfo\":\"JS Client\",\"paymentMethodTokenPreviouslyRegistered\":null,\"occurrenceType\":\"RECURRENT_INITIAL\",\"archivalReferenceId\":\"L1799g1h4e01\",\"useCase\":null,\"wallet\":null,\"_type\":\"V4/TransactionDetails\"},\"_type\":\"V4/PaymentTransaction\"}],\"subMerchantDetails\":null,\"_type\":\"V4/Payment\"}", + "kr-answer-type": "V4/Payment", + "kr-hash": "017b828697c975ea75fcc6559078118bbadb72acf474455b77e59b7b0e5822a8" +} diff --git a/src/backend/joanie/tests/payment/lyra/requests/tokenize_card_for_user_answer.json b/src/backend/joanie/tests/payment/lyra/requests/tokenize_card_for_user_answer.json new file mode 100644 index 000000000..e07b4a23b --- /dev/null +++ b/src/backend/joanie/tests/payment/lyra/requests/tokenize_card_for_user_answer.json @@ -0,0 +1,362 @@ +{ + "shopId": "79264058", + "orderCycle": "CLOSED", + "orderStatus": "PAID", + "serverDate": "2024-06-27T14:52:47+00:00", + "orderDetails": { + "orderTotalAmount": 0, + "orderEffectiveAmount": 0, + "orderCurrency": "EUR", + "mode": "TEST", + "orderId": null, + "metadata": null, + "_type": "V4/OrderDetails" + }, + "customer": { + "billingDetails": { + "address": null, + "category": null, + "cellPhoneNumber": null, + "city": null, + "country": null, + "district": null, + "firstName": null, + "identityCode": null, + "identityType": null, + "language": "EN", + "lastName": null, + "phoneNumber": null, + "state": null, + "streetNumber": null, + "title": null, + "zipCode": null, + "legalName": null, + "_type": "V4/Customer/BillingDetails" + }, + "email": "john.doe@acme.org", + "reference": "0a920c52-7ecc-47b3-83f5-127b846ac79c", + "shippingDetails": { + "address": null, + "address2": null, + "category": null, + "city": null, + "country": null, + "deliveryCompanyName": null, + "district": null, + "firstName": null, + "identityCode": null, + "lastName": null, + "legalName": null, + "phoneNumber": null, + "shippingMethod": null, + "shippingSpeed": null, + "state": null, + "streetNumber": null, + "zipCode": null, + "_type": "V4/Customer/ShippingDetails" + }, + "extraDetails": { + "browserAccept": null, + "fingerPrintId": null, + "ipAddress": "51.75.249.201", + "browserUserAgent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0", + "_type": "V4/Customer/ExtraDetails" + }, + "shoppingCart": { + "insuranceAmount": null, + "shippingAmount": null, + "taxAmount": null, + "cartItemInfo": null, + "_type": "V4/Customer/ShoppingCart" + }, + "_type": "V4/Customer/Customer" + }, + "transactions": [ + { + "shopId": "79264058", + "uuid": "622cf59b8ac5495ea67a937addc3060c", + "amount": 0, + "currency": "EUR", + "paymentMethodType": "CARD", + "paymentMethodToken": "cedab61905974afe9794c87085543dba", + "status": "PAID", + "detailedStatus": "ACCEPTED", + "operationType": "VERIFICATION", + "effectiveStrongAuthentication": "ENABLED", + "creationDate": "2024-06-27T14:52:46+00:00", + "errorCode": null, + "errorMessage": null, + "detailedErrorCode": null, + "detailedErrorMessage": null, + "metadata": null, + "transactionDetails": { + "liabilityShift": "NO", + "effectiveAmount": 0, + "effectiveCurrency": "EUR", + "creationContext": "VERIFICATION", + "cardDetails": { + "paymentSource": "EC", + "manualValidation": "NO", + "expectedCaptureDate": null, + "effectiveBrand": "VISA", + "pan": "497011XXXXXX1003", + "expiryMonth": 12, + "expiryYear": 2025, + "country": "FR", + "issuerCode": 17807, + "issuerName": "Banque Populaire Occitane", + "effectiveProductCode": null, + "legacyTransId": "9g1h4e", + "legacyTransDate": "2024-06-27T14:52:46+00:00", + "paymentMethodSource": "TOKEN", + "authorizationResponse": { + "amount": null, + "currency": null, + "authorizationDate": null, + "authorizationNumber": null, + "authorizationResult": null, + "authorizationMode": "MARK", + "_type": "V4/PaymentMethod/Details/Cards/CardAuthorizationResponse" + }, + "captureResponse": { + "refundAmount": null, + "refundCurrency": null, + "captureDate": null, + "captureFileNumber": null, + "effectiveRefundAmount": null, + "effectiveRefundCurrency": null, + "_type": "V4/PaymentMethod/Details/Cards/CardCaptureResponse" + }, + "threeDSResponse": { + "authenticationResultData": { + "transactionCondition": null, + "enrolled": null, + "status": null, + "eci": null, + "xid": null, + "cavvAlgorithm": null, + "cavv": null, + "signValid": null, + "brand": null, + "_type": "V4/PaymentMethod/Details/Cards/CardAuthenticationResponse" + }, + "_type": "V4/PaymentMethod/Details/Cards/ThreeDSResponse" + }, + "authenticationResponse": { + "id": "3f198c4d-de23-49bc-9b9e-35a94cbc89c4", + "operationSessionId": null, + "protocol": { + "name": "THREEDS", + "version": "2.1.0", + "network": "VISA", + "challengePreference": "CHALLENGE_MANDATED", + "simulation": true, + "_type": "V4/Charge/Authenticate/Protocol" + }, + "value": { + "authenticationType": "CHALLENGE", + "authenticationId": { + "authenticationIdType": "dsTransId", + "value": "5dab1964-ad95-4715-86c0-32557e6f5b46", + "_type": "V4/Charge/Authenticate/AuthenticationId" + }, + "authenticationValue": { + "authenticationValueType": "CAVV", + "value": "t**************************=", + "_type": "V4/Charge/Authenticate/AuthenticationValue" + }, + "status": "SUCCESS", + "commerceIndicator": "05", + "extension": { + "authenticationType": "THREEDS_V2", + "challengeCancelationIndicator": null, + "cbScore": null, + "cbAvalgo": null, + "cbExemption": null, + "paymentUseCase": null, + "threeDSServerTransID": "3f198c4d-de23-49bc-9b9e-35a94cbc89c4", + "dsTransID": "5dab1964-ad95-4715-86c0-32557e6f5b46", + "acsTransID": "d72df9dc-893d-4984-98ac-500a842227fd", + "sdkTransID": null, + "transStatusReason": null, + "requestedExemption": null, + "requestorName": "FUN MOOC", + "cardHolderInfo": null, + "dataOnlyStatus": null, + "dataOnlyDecision": null, + "dataOnlyScore": null, + "_type": "V4/Charge/Authenticate/AuthenticationResultExtensionThreedsV2" + }, + "reason": { + "code": null, + "message": null, + "_type": "V4/Charge/Authenticate/AuthenticationResultReason" + }, + "_type": "V4/Charge/Authenticate/AuthenticationResult" + }, + "_type": "V4/AuthenticationResponseData" + }, + "installmentNumber": null, + "installmentCode": null, + "markAuthorizationResponse": { + "amount": 0, + "currency": "EUR", + "authorizationDate": "2024-06-27T14:52:46+00:00", + "authorizationNumber": "3fefad", + "authorizationResult": "0", + "_type": "V4/PaymentMethod/Details/Cards/MarkAuthorizationResponse" + }, + "cardHolderName": null, + "cardHolderPan": "497011XXXXXX1003", + "cardHolderExpiryMonth": 12, + "cardHolderExpiryYear": 2025, + "identityDocumentNumber": null, + "identityDocumentType": null, + "initialIssuerTransactionIdentifier": "1873524233492261", + "productCategory": "DEBIT", + "nature": "CONSUMER_CARD", + "_type": "V4/PaymentMethod/Details/CardDetails" + }, + "paymentMethodDetails": { + "id": "497011XXXXXX1003", + "paymentSource": "EC", + "manualValidation": "NO", + "expectedCaptureDate": null, + "effectiveBrand": "VISA", + "expiryMonth": 12, + "expiryYear": 2025, + "country": "FR", + "issuerCode": 17807, + "issuerName": "Banque Populaire Occitane", + "effectiveProductCode": null, + "legacyTransId": "9g1h4e", + "legacyTransDate": "2024-06-27T14:52:46+00:00", + "paymentMethodSource": "TOKEN", + "authorizationResponse": { + "amount": null, + "currency": null, + "authorizationDate": null, + "authorizationNumber": null, + "authorizationResult": null, + "authorizationMode": "MARK", + "_type": "V4/PaymentMethod/Details/Cards/CardAuthorizationResponse" + }, + "captureResponse": { + "refundAmount": null, + "refundCurrency": null, + "captureDate": null, + "captureFileNumber": null, + "effectiveRefundAmount": null, + "effectiveRefundCurrency": null, + "_type": "V4/PaymentMethod/Details/Cards/CardCaptureResponse" + }, + "authenticationResponse": { + "id": "3f198c4d-de23-49bc-9b9e-35a94cbc89c4", + "operationSessionId": null, + "protocol": { + "name": "THREEDS", + "version": "2.1.0", + "network": "VISA", + "challengePreference": "CHALLENGE_MANDATED", + "simulation": true, + "_type": "V4/Charge/Authenticate/Protocol" + }, + "value": { + "authenticationType": "CHALLENGE", + "authenticationId": { + "authenticationIdType": "dsTransId", + "value": "5dab1964-ad95-4715-86c0-32557e6f5b46", + "_type": "V4/Charge/Authenticate/AuthenticationId" + }, + "authenticationValue": { + "authenticationValueType": "CAVV", + "value": "t**************************=", + "_type": "V4/Charge/Authenticate/AuthenticationValue" + }, + "status": "SUCCESS", + "commerceIndicator": "05", + "extension": { + "authenticationType": "THREEDS_V2", + "challengeCancelationIndicator": null, + "cbScore": null, + "cbAvalgo": null, + "cbExemption": null, + "paymentUseCase": null, + "threeDSServerTransID": "3f198c4d-de23-49bc-9b9e-35a94cbc89c4", + "dsTransID": "5dab1964-ad95-4715-86c0-32557e6f5b46", + "acsTransID": "d72df9dc-893d-4984-98ac-500a842227fd", + "sdkTransID": null, + "transStatusReason": null, + "requestedExemption": null, + "requestorName": "FUN MOOC", + "cardHolderInfo": null, + "dataOnlyStatus": null, + "dataOnlyDecision": null, + "dataOnlyScore": null, + "_type": "V4/Charge/Authenticate/AuthenticationResultExtensionThreedsV2" + }, + "reason": { + "code": null, + "message": null, + "_type": "V4/Charge/Authenticate/AuthenticationResultReason" + }, + "_type": "V4/Charge/Authenticate/AuthenticationResult" + }, + "_type": "V4/AuthenticationResponseData" + }, + "installmentNumber": null, + "installmentCode": null, + "markAuthorizationResponse": { + "amount": 0, + "currency": "EUR", + "authorizationDate": "2024-06-27T14:52:46+00:00", + "authorizationNumber": "3fefad", + "authorizationResult": "0", + "_type": "V4/PaymentMethod/Details/Cards/MarkAuthorizationResponse" + }, + "cardHolderName": null, + "cardHolderPan": "497011XXXXXX1003", + "cardHolderExpiryMonth": 12, + "cardHolderExpiryYear": 2025, + "identityDocumentNumber": null, + "identityDocumentType": null, + "initialIssuerTransactionIdentifier": "1873524233492261", + "_type": "V4/PaymentMethod/Details/PaymentMethodDetails" + }, + "acquirerDetails": null, + "fraudManagement": { + "riskControl": [], + "riskAnalysis": [], + "riskAssessments": null, + "_type": "V4/PaymentMethod/Details/FraudManagement" + }, + "subscriptionDetails": { + "subscriptionId": null, + "_type": "V4/PaymentMethod/Details/SubscriptionDetails" + }, + "parentTransactionUuid": null, + "mid": "2357367", + "sequenceNumber": 1, + "taxAmount": null, + "preTaxAmount": null, + "taxRate": null, + "externalTransactionId": null, + "dcc": null, + "nsu": null, + "tid": "001", + "acquirerNetwork": "CB", + "taxRefundAmount": null, + "userInfo": "JS Client", + "paymentMethodTokenPreviouslyRegistered": null, + "occurrenceType": "RECURRENT_INITIAL", + "archivalReferenceId": "L1799g1h4e01", + "useCase": null, + "wallet": null, + "_type": "V4/TransactionDetails" + }, + "_type": "V4/PaymentTransaction" + } + ], + "subMerchantDetails": null, + "_type": "V4/Payment" +} diff --git a/src/backend/joanie/tests/payment/lyra/requests/tokenize_card_for_user_unpaid.json b/src/backend/joanie/tests/payment/lyra/requests/tokenize_card_for_user_unpaid.json new file mode 100644 index 000000000..dedbdaa1c --- /dev/null +++ b/src/backend/joanie/tests/payment/lyra/requests/tokenize_card_for_user_unpaid.json @@ -0,0 +1,7 @@ +{ + "kr-hash-key": "password", + "kr-hash-algorithm": "sha256_hmac", + "kr-answer": "{\"shopId\":\"79264058\",\"orderCycle\":\"CLOSED\",\"orderStatus\":\"UNPAID\",\"serverDate\":\"2024-06-27T14:52:47+00:00\",\"orderDetails\":{\"orderTotalAmount\":0,\"orderEffectiveAmount\":0,\"orderCurrency\":\"EUR\",\"mode\":\"TEST\",\"orderId\":null,\"metadata\":null,\"_type\":\"V4/OrderDetails\"},\"customer\":{\"billingDetails\":{\"address\":null,\"category\":null,\"cellPhoneNumber\":null,\"city\":null,\"country\":null,\"district\":null,\"firstName\":null,\"identityCode\":null,\"identityType\":null,\"language\":\"EN\",\"lastName\":null,\"phoneNumber\":null,\"state\":null,\"streetNumber\":null,\"title\":null,\"zipCode\":null,\"legalName\":null,\"_type\":\"V4/Customer/BillingDetails\"},\"email\":\"john.doe@acme.org\",\"reference\":\"0a920c52-7ecc-47b3-83f5-127b846ac79c\",\"shippingDetails\":{\"address\":null,\"address2\":null,\"category\":null,\"city\":null,\"country\":null,\"deliveryCompanyName\":null,\"district\":null,\"firstName\":null,\"identityCode\":null,\"lastName\":null,\"legalName\":null,\"phoneNumber\":null,\"shippingMethod\":null,\"shippingSpeed\":null,\"state\":null,\"streetNumber\":null,\"zipCode\":null,\"_type\":\"V4/Customer/ShippingDetails\"},\"extraDetails\":{\"browserAccept\":null,\"fingerPrintId\":null,\"ipAddress\":\"51.75.249.201\",\"browserUserAgent\":\"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0\",\"_type\":\"V4/Customer/ExtraDetails\"},\"shoppingCart\":{\"insuranceAmount\":null,\"shippingAmount\":null,\"taxAmount\":null,\"cartItemInfo\":null,\"_type\":\"V4/Customer/ShoppingCart\"},\"_type\":\"V4/Customer/Customer\"},\"transactions\":[{\"shopId\":\"79264058\",\"uuid\":\"622cf59b8ac5495ea67a937addc3060c\",\"amount\":0,\"currency\":\"EUR\",\"paymentMethodType\":\"CARD\",\"paymentMethodToken\":\"cedab61905974afe9794c87085543dba\",\"status\":\"PAID\",\"detailedStatus\":\"ACCEPTED\",\"operationType\":\"VERIFICATION\",\"effectiveStrongAuthentication\":\"ENABLED\",\"creationDate\":\"2024-06-27T14:52:46+00:00\",\"errorCode\":null,\"errorMessage\":null,\"detailedErrorCode\":null,\"detailedErrorMessage\":null,\"metadata\":null,\"transactionDetails\":{\"liabilityShift\":\"NO\",\"effectiveAmount\":0,\"effectiveCurrency\":\"EUR\",\"creationContext\":\"VERIFICATION\",\"cardDetails\":{\"paymentSource\":\"EC\",\"manualValidation\":\"NO\",\"expectedCaptureDate\":null,\"effectiveBrand\":\"VISA\",\"pan\":\"497011XXXXXX1003\",\"expiryMonth\":12,\"expiryYear\":2025,\"country\":\"FR\",\"issuerCode\":17807,\"issuerName\":\"Banque Populaire Occitane\",\"effectiveProductCode\":null,\"legacyTransId\":\"9g1h4e\",\"legacyTransDate\":\"2024-06-27T14:52:46+00:00\",\"paymentMethodSource\":\"TOKEN\",\"authorizationResponse\":{\"amount\":null,\"currency\":null,\"authorizationDate\":null,\"authorizationNumber\":null,\"authorizationResult\":null,\"authorizationMode\":\"MARK\",\"_type\":\"V4/PaymentMethod/Details/Cards/CardAuthorizationResponse\"},\"captureResponse\":{\"refundAmount\":null,\"refundCurrency\":null,\"captureDate\":null,\"captureFileNumber\":null,\"effectiveRefundAmount\":null,\"effectiveRefundCurrency\":null,\"_type\":\"V4/PaymentMethod/Details/Cards/CardCaptureResponse\"},\"threeDSResponse\":{\"authenticationResultData\":{\"transactionCondition\":null,\"enrolled\":null,\"status\":null,\"eci\":null,\"xid\":null,\"cavvAlgorithm\":null,\"cavv\":null,\"signValid\":null,\"brand\":null,\"_type\":\"V4/PaymentMethod/Details/Cards/CardAuthenticationResponse\"},\"_type\":\"V4/PaymentMethod/Details/Cards/ThreeDSResponse\"},\"authenticationResponse\":{\"id\":\"3f198c4d-de23-49bc-9b9e-35a94cbc89c4\",\"operationSessionId\":null,\"protocol\":{\"name\":\"THREEDS\",\"version\":\"2.1.0\",\"network\":\"VISA\",\"challengePreference\":\"CHALLENGE_MANDATED\",\"simulation\":true,\"_type\":\"V4/Charge/Authenticate/Protocol\"},\"value\":{\"authenticationType\":\"CHALLENGE\",\"authenticationId\":{\"authenticationIdType\":\"dsTransId\",\"value\":\"5dab1964-ad95-4715-86c0-32557e6f5b46\",\"_type\":\"V4/Charge/Authenticate/AuthenticationId\"},\"authenticationValue\":{\"authenticationValueType\":\"CAVV\",\"value\":\"t**************************=\",\"_type\":\"V4/Charge/Authenticate/AuthenticationValue\"},\"status\":\"SUCCESS\",\"commerceIndicator\":\"05\",\"extension\":{\"authenticationType\":\"THREEDS_V2\",\"challengeCancelationIndicator\":null,\"cbScore\":null,\"cbAvalgo\":null,\"cbExemption\":null,\"paymentUseCase\":null,\"threeDSServerTransID\":\"3f198c4d-de23-49bc-9b9e-35a94cbc89c4\",\"dsTransID\":\"5dab1964-ad95-4715-86c0-32557e6f5b46\",\"acsTransID\":\"d72df9dc-893d-4984-98ac-500a842227fd\",\"sdkTransID\":null,\"transStatusReason\":null,\"requestedExemption\":null,\"requestorName\":\"FUN MOOC\",\"cardHolderInfo\":null,\"dataOnlyStatus\":null,\"dataOnlyDecision\":null,\"dataOnlyScore\":null,\"_type\":\"V4/Charge/Authenticate/AuthenticationResultExtensionThreedsV2\"},\"reason\":{\"code\":null,\"message\":null,\"_type\":\"V4/Charge/Authenticate/AuthenticationResultReason\"},\"_type\":\"V4/Charge/Authenticate/AuthenticationResult\"},\"_type\":\"V4/AuthenticationResponseData\"},\"installmentNumber\":null,\"installmentCode\":null,\"markAuthorizationResponse\":{\"amount\":0,\"currency\":\"EUR\",\"authorizationDate\":\"2024-06-27T14:52:46+00:00\",\"authorizationNumber\":\"3fefad\",\"authorizationResult\":\"0\",\"_type\":\"V4/PaymentMethod/Details/Cards/MarkAuthorizationResponse\"},\"cardHolderName\":null,\"cardHolderPan\":\"497011XXXXXX1003\",\"cardHolderExpiryMonth\":12,\"cardHolderExpiryYear\":2025,\"identityDocumentNumber\":null,\"identityDocumentType\":null,\"initialIssuerTransactionIdentifier\":\"1873524233492261\",\"productCategory\":\"DEBIT\",\"nature\":\"CONSUMER_CARD\",\"_type\":\"V4/PaymentMethod/Details/CardDetails\"},\"paymentMethodDetails\":{\"id\":\"497011XXXXXX1003\",\"paymentSource\":\"EC\",\"manualValidation\":\"NO\",\"expectedCaptureDate\":null,\"effectiveBrand\":\"VISA\",\"expiryMonth\":12,\"expiryYear\":2025,\"country\":\"FR\",\"issuerCode\":17807,\"issuerName\":\"Banque Populaire Occitane\",\"effectiveProductCode\":null,\"legacyTransId\":\"9g1h4e\",\"legacyTransDate\":\"2024-06-27T14:52:46+00:00\",\"paymentMethodSource\":\"TOKEN\",\"authorizationResponse\":{\"amount\":null,\"currency\":null,\"authorizationDate\":null,\"authorizationNumber\":null,\"authorizationResult\":null,\"authorizationMode\":\"MARK\",\"_type\":\"V4/PaymentMethod/Details/Cards/CardAuthorizationResponse\"},\"captureResponse\":{\"refundAmount\":null,\"refundCurrency\":null,\"captureDate\":null,\"captureFileNumber\":null,\"effectiveRefundAmount\":null,\"effectiveRefundCurrency\":null,\"_type\":\"V4/PaymentMethod/Details/Cards/CardCaptureResponse\"},\"authenticationResponse\":{\"id\":\"3f198c4d-de23-49bc-9b9e-35a94cbc89c4\",\"operationSessionId\":null,\"protocol\":{\"name\":\"THREEDS\",\"version\":\"2.1.0\",\"network\":\"VISA\",\"challengePreference\":\"CHALLENGE_MANDATED\",\"simulation\":true,\"_type\":\"V4/Charge/Authenticate/Protocol\"},\"value\":{\"authenticationType\":\"CHALLENGE\",\"authenticationId\":{\"authenticationIdType\":\"dsTransId\",\"value\":\"5dab1964-ad95-4715-86c0-32557e6f5b46\",\"_type\":\"V4/Charge/Authenticate/AuthenticationId\"},\"authenticationValue\":{\"authenticationValueType\":\"CAVV\",\"value\":\"t**************************=\",\"_type\":\"V4/Charge/Authenticate/AuthenticationValue\"},\"status\":\"SUCCESS\",\"commerceIndicator\":\"05\",\"extension\":{\"authenticationType\":\"THREEDS_V2\",\"challengeCancelationIndicator\":null,\"cbScore\":null,\"cbAvalgo\":null,\"cbExemption\":null,\"paymentUseCase\":null,\"threeDSServerTransID\":\"3f198c4d-de23-49bc-9b9e-35a94cbc89c4\",\"dsTransID\":\"5dab1964-ad95-4715-86c0-32557e6f5b46\",\"acsTransID\":\"d72df9dc-893d-4984-98ac-500a842227fd\",\"sdkTransID\":null,\"transStatusReason\":null,\"requestedExemption\":null,\"requestorName\":\"FUN MOOC\",\"cardHolderInfo\":null,\"dataOnlyStatus\":null,\"dataOnlyDecision\":null,\"dataOnlyScore\":null,\"_type\":\"V4/Charge/Authenticate/AuthenticationResultExtensionThreedsV2\"},\"reason\":{\"code\":null,\"message\":null,\"_type\":\"V4/Charge/Authenticate/AuthenticationResultReason\"},\"_type\":\"V4/Charge/Authenticate/AuthenticationResult\"},\"_type\":\"V4/AuthenticationResponseData\"},\"installmentNumber\":null,\"installmentCode\":null,\"markAuthorizationResponse\":{\"amount\":0,\"currency\":\"EUR\",\"authorizationDate\":\"2024-06-27T14:52:46+00:00\",\"authorizationNumber\":\"3fefad\",\"authorizationResult\":\"0\",\"_type\":\"V4/PaymentMethod/Details/Cards/MarkAuthorizationResponse\"},\"cardHolderName\":null,\"cardHolderPan\":\"497011XXXXXX1003\",\"cardHolderExpiryMonth\":12,\"cardHolderExpiryYear\":2025,\"identityDocumentNumber\":null,\"identityDocumentType\":null,\"initialIssuerTransactionIdentifier\":\"1873524233492261\",\"_type\":\"V4/PaymentMethod/Details/PaymentMethodDetails\"},\"acquirerDetails\":null,\"fraudManagement\":{\"riskControl\":[],\"riskAnalysis\":[],\"riskAssessments\":null,\"_type\":\"V4/PaymentMethod/Details/FraudManagement\"},\"subscriptionDetails\":{\"subscriptionId\":null,\"_type\":\"V4/PaymentMethod/Details/SubscriptionDetails\"},\"parentTransactionUuid\":null,\"mid\":\"2357367\",\"sequenceNumber\":1,\"taxAmount\":null,\"preTaxAmount\":null,\"taxRate\":null,\"externalTransactionId\":null,\"dcc\":null,\"nsu\":null,\"tid\":\"001\",\"acquirerNetwork\":\"CB\",\"taxRefundAmount\":null,\"userInfo\":\"JS Client\",\"paymentMethodTokenPreviouslyRegistered\":null,\"occurrenceType\":\"RECURRENT_INITIAL\",\"archivalReferenceId\":\"L1799g1h4e01\",\"useCase\":null,\"wallet\":null,\"_type\":\"V4/TransactionDetails\"},\"_type\":\"V4/PaymentTransaction\"}],\"subMerchantDetails\":null,\"_type\":\"V4/Payment\"}", + "kr-answer-type": "V4/Payment", + "kr-hash": "bf96a3d1faa0f3c2437fca9fca4b600adf338a724e1b5f2d7ee0bf42476139bb" +} diff --git a/src/backend/joanie/tests/payment/test_admin_invoice.py b/src/backend/joanie/tests/payment/test_admin_invoice.py index f38998e7d..c12d032c5 100644 --- a/src/backend/joanie/tests/payment/test_admin_invoice.py +++ b/src/backend/joanie/tests/payment/test_admin_invoice.py @@ -24,7 +24,7 @@ def test_admin_invoice_display_human_readable_type(self): self.client.login(username=user.username, password="password") # - Create an order with a related invoice - order = factories.OrderFactory(state=enums.ORDER_STATE_VALIDATED) + order = factories.OrderFactory(state=enums.ORDER_STATE_COMPLETED) credit_note = InvoiceFactory( order=order, parent=order.main_invoice, total=-order.total ) @@ -53,7 +53,7 @@ def test_admin_invoice_display_balances(self): self.client.login(username=user.username, password="password") # - Create an order with a related invoice - order = factories.OrderFactory(state=enums.ORDER_STATE_VALIDATED) + order = factories.OrderFactory(state=enums.ORDER_STATE_COMPLETED) TransactionFactory(invoice__parent=order.main_invoice, total=-order.total) # - Now go to the invoice admin change view @@ -86,7 +86,7 @@ def test_admin_invoice_display_invoice_children_as_link(self): self.client.login(username=user.username, password="password") # - Create an order with a related invoice - order = factories.OrderFactory(state=enums.ORDER_STATE_VALIDATED) + order = factories.OrderFactory(state=enums.ORDER_STATE_COMPLETED) # - And link other invoices to this invoice children = InvoiceFactory.create_batch( diff --git a/src/backend/joanie/tests/payment/test_api_credit_card.py b/src/backend/joanie/tests/payment/test_api_credit_card.py index 7d2adabbd..be8639f39 100644 --- a/src/backend/joanie/tests/payment/test_api_credit_card.py +++ b/src/backend/joanie/tests/payment/test_api_credit_card.py @@ -20,24 +20,24 @@ class CreditCardAPITestCase(BaseAPITestCase): """Manage user's credit cards API test cases""" - def test_api_credit_card_get_credit_cards_without_authorization(self): - """Retrieve credit cards without authorization header is forbidden.""" + def test_api_credit_card_list_without_authorization(self): + """List credit cards without authorization header is forbidden.""" response = self.client.get("/api/v1.0/credit-cards/") self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED) self.assertEqual( response.data, {"detail": "Authentication credentials were not provided."} ) - def test_api_credit_card_get_credit_cards_with_bad_token(self): - """Retrieve credit cards with bad token is forbidden.""" + def test_api_credit_card_list_with_bad_token(self): + """List credit cards with bad token is forbidden.""" response = self.client.get( "/api/v1.0/credit-cards/", HTTP_AUTHORIZATION="Bearer invalid_token" ) self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED) self.assertEqual(response.data["code"], "token_not_valid") - def test_api_credit_card_get_credit_cards_with_expired_token(self): - """Retrieve credit cards with an expired token is forbidden.""" + def test_api_credit_card_list_with_expired_token(self): + """List credit cards with an expired token is forbidden.""" token = self.get_user_token( "johndoe", expires_at=arrow.utcnow().shift(days=-1).datetime, @@ -48,10 +48,9 @@ def test_api_credit_card_get_credit_cards_with_expired_token(self): self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED) self.assertEqual(response.data["code"], "token_not_valid") - def test_api_credit_card_get_credit_cards_for_new_user(self): + def test_api_credit_card_list_for_new_user(self): """ - Retrieve credit cards of a non existing user is allowed but - create an user first. + List credit cards of a non-existing user is allowed but create an user first. """ username = "johndoe" self.assertFalse(User.objects.filter(username=username).exists()) @@ -65,9 +64,9 @@ def test_api_credit_card_get_credit_cards_for_new_user(self): ) self.assertTrue(User.objects.filter(username=username).exists()) - def test_api_credit_card_get_credit_cards_list(self): + def test_api_credit_card_list(self): """ - Authenticated user should be able to retrieve all his credit cards + Authenticated user should be able to list all his credit cards with the active payment backend. """ user = UserFactory() @@ -85,6 +84,7 @@ def test_api_credit_card_get_credit_cards_list(self): content = response.json() results = content.pop("results") cards.sort(key=lambda card: card.created_on, reverse=True) + cards.sort(key=lambda card: card.is_main, reverse=True) self.assertEqual( [result["id"] for result in results], [str(card.id) for card in cards] ) @@ -99,7 +99,7 @@ def test_api_credit_card_get_credit_cards_list(self): ) @mock.patch.object(PageNumberPagination, "get_page_size", return_value=2) - def test_api_credit_card_read_list_pagination(self, _mock_page_size): + def test_api_credit_card_list_pagination(self, _mock_page_size): """Pagination should work as expected.""" user = UserFactory() token = self.generate_token_from_user(user) @@ -140,7 +140,45 @@ def test_api_credit_card_read_list_pagination(self, _mock_page_size): card_ids.remove(content["results"][0]["id"]) self.assertEqual(card_ids, []) - def test_api_credit_card_get_credit_card(self): + @mock.patch.object(PageNumberPagination, "get_page_size", return_value=2) + def test_api_credit_card_list_sorted_by_is_main_then_created_on( + self, _mock_page_size + ): + """ + List credit cards should always return first the main credit card then + all others sorted by created_on desc. + """ + user = UserFactory() + token = self.generate_token_from_user(user) + cards = CreditCardFactory.create_batch(3, owner=user) + cards.sort(key=lambda card: card.created_on, reverse=True) + cards.sort(key=lambda card: card.is_main, reverse=True) + sorted_card_ids = [str(card.id) for card in cards] + + response = self.client.get( + "/api/v1.0/credit-cards/", HTTP_AUTHORIZATION=f"Bearer {token}" + ) + + self.assertEqual(response.status_code, HTTPStatus.OK) + content = response.json() + self.assertEqual(content["count"], 3) + results_ids = [result["id"] for result in content["results"]] + self.assertListEqual(results_ids, sorted_card_ids[:2]) + self.assertEqual(content["results"][0]["is_main"], True) + + # Get page 2 + response = self.client.get( + "/api/v1.0/credit-cards/?page=2", HTTP_AUTHORIZATION=f"Bearer {token}" + ) + + self.assertEqual(response.status_code, HTTPStatus.OK) + content = response.json() + + self.assertEqual(content["count"], 3) + results_ids = [result["id"] for result in content["results"]] + self.assertListEqual(results_ids, sorted_card_ids[2:]) + + def test_api_credit_card_get(self): """Retrieve authenticated user's credit card by its id is allowed.""" user = UserFactory() token = self.generate_token_from_user(user) @@ -165,8 +203,8 @@ def test_api_credit_card_get_credit_card(self): }, ) - def test_api_credit_card_get_non_existing_credit_card(self): - """Retrieve a non existing credit card should return a 404.""" + def test_api_credit_card_get_non_existing(self): + """Retrieve a non-existing credit card should return a 404.""" user = UserFactory() token = self.generate_token_from_user(user) card = CreditCardFactory.build(owner=user) @@ -176,10 +214,9 @@ def test_api_credit_card_get_non_existing_credit_card(self): ) self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND) - def test_api_credit_card_get_not_owned_credit_card(self): + def test_api_credit_card_get_not_owned(self): """ - Retrieve credit card don't owned by the - authenticated user should return a 404. + Retrieve credit card don't owned by the authenticated user should return a 404. """ user = UserFactory() token = self.generate_token_from_user(user) @@ -190,7 +227,7 @@ def test_api_credit_card_get_not_owned_credit_card(self): ) self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND) - def test_api_credit_card_create_credit_card_is_not_allowed(self): + def test_api_credit_card_create_is_not_allowed(self): """Create a credit card is not allowed.""" token = self.get_user_token("johndoe") response = self.client.post( @@ -311,7 +348,7 @@ def test_api_credit_card_promote_credit_card(self): def test_api_credit_card_update(self): """ - Update a authenticated user's credit card is allowed with a valid token. + Update an authenticated user's credit card is allowed with a valid token. Only title field should be writable ! """ user = UserFactory() diff --git a/src/backend/joanie/tests/payment/test_backend_base.py b/src/backend/joanie/tests/payment/test_backend_base.py index e9cfb72ea..ec0f020f7 100644 --- a/src/backend/joanie/tests/payment/test_backend_base.py +++ b/src/backend/joanie/tests/payment/test_backend_base.py @@ -1,17 +1,30 @@ """Test suite of the Base Payment backend""" import smtplib +from datetime import date +from decimal import Decimal from logging import Logger from unittest import mock from django.core import mail from django.test import override_settings +from stockholm import Money + from joanie.core import enums -from joanie.core.factories import OrderFactory, UserAddressFactory, UserFactory +from joanie.core.factories import ( + OrderFactory, + ProductFactory, + UserAddressFactory, + UserFactory, +) from joanie.core.models import Address from joanie.payment.backends.base import BasePaymentBackend -from joanie.payment.factories import BillingAddressDictFactory +from joanie.payment.factories import ( + BillingAddressDictFactory, + CreditCardFactory, + InvoiceFactory, +) from joanie.payment.models import Transaction from joanie.tests.base import ActivityLogMixingTestCase from joanie.tests.payment.base_payment import BasePaymentTestCase @@ -38,14 +51,14 @@ def abort_payment(self, payment_id): pass def create_one_click_payment( - self, order, billing_address, credit_card_token, installment=None + self, order, installment, credit_card_token, billing_address ): pass - def create_payment(self, order, billing_address, installment=None): + def create_payment(self, order, installment, billing_address): pass - def create_zero_click_payment(self, order, credit_card_token, installment=None): + def create_zero_click_payment(self, order, installment, credit_card_token): pass def delete_credit_card(self, credit_card): @@ -58,6 +71,7 @@ def tokenize_card(self, order=None, billing_address=None, user=None): pass +# pylint: disable=too-many-public-methods, too-many-lines @override_settings(JOANIE_CATALOG_NAME="Test Catalog") @override_settings(JOANIE_CATALOG_BASE_URL="https://richie.education") class BasePaymentBackendTestCase(BasePaymentTestCase, ActivityLogMixingTestCase): @@ -83,7 +97,7 @@ def test_payment_backend_base_create_payment_not_implemented(self): backend = BasePaymentBackend() with self.assertRaises(NotImplementedError) as context: - backend.create_payment(None, None) + backend.create_payment(None, None, None) self.assertEqual( str(context.exception), @@ -95,7 +109,7 @@ def test_payment_backend_base_create_one_click_payment_not_implemented(self): backend = BasePaymentBackend() with self.assertRaises(NotImplementedError) as context: - backend.create_one_click_payment(None, None, None) + backend.create_one_click_payment(None, None, None, None) self.assertEqual( str(context.exception), @@ -172,12 +186,28 @@ def test_payment_backend_base_do_on_payment_success(self): """ backend = TestBasePaymentBackend() owner = UserFactory(email="sam@fun-test.fr", language="en-us") - order = OrderFactory(owner=owner, state=enums.ORDER_STATE_SUBMITTED) + order = OrderFactory( + owner=owner, + product__price=Decimal("200.00"), + payment_schedule=[ + { + "id": "d9356dd7-19a6-4695-b18e-ad93af41424a", + "amount": "200.00", + "due_date": "2024-01-17", + "state": enums.PAYMENT_STATE_PENDING, + }, + ], + ) + CreditCardFactory( + owner=owner, is_main=True, initial_issuer_transaction_identifier="1" + ) billing_address = BillingAddressDictFactory() + order.init_flow(billing_address=billing_address) payment = { "id": "pay_0", "amount": order.total, "billing_address": billing_address, + "installment_id": "d9356dd7-19a6-4695-b18e-ad93af41424a", } backend.call_do_on_payment_success(order, payment) @@ -203,13 +233,11 @@ def test_payment_backend_base_do_on_payment_success(self): self.assertIsNotNone(order.main_invoice) self.assertEqual(order.main_invoice.children.count(), 1) - # - Order has been validated - self.assertEqual(order.state, "validated") + # - Order has been completed + self.assertEqual(order.state, enums.ORDER_STATE_COMPLETED) # - Email has been sent - self._check_order_validated_email_sent( - "sam@fun-test.fr", owner.get_full_name(), order - ) + self._check_installment_paid_email_sent("sam@fun-test.fr", order) # - An event has been created self.assertPaymentSuccessActivityLog(order) @@ -224,9 +252,12 @@ def test_payment_backend_base_do_on_payment_success_with_installment(self): """ backend = TestBasePaymentBackend() owner = UserFactory(email="sam@fun-test.fr", language="en-us") + CreditCardFactory( + owner=owner, is_main=True, initial_issuer_transaction_identifier="1" + ) order = OrderFactory( owner=owner, - state=enums.ORDER_STATE_PENDING, + product__price=Decimal("999.99"), payment_schedule=[ { "id": "d9356dd7-19a6-4695-b18e-ad93af41424a", @@ -261,6 +292,7 @@ def test_payment_backend_base_do_on_payment_success_with_installment(self): "billing_address": billing_address, "installment_id": "d9356dd7-19a6-4695-b18e-ad93af41424a", } + order.init_flow(billing_address=billing_address) backend.call_do_on_payment_success(order, payment) @@ -293,35 +325,33 @@ def test_payment_backend_base_do_on_payment_success_with_installment(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": enums.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": enums.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": enums.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": enums.PAYMENT_STATE_PENDING, }, ], ) # - Email has been sent - self._check_order_validated_email_sent( - "sam@fun-test.fr", owner.get_full_name(), order - ) + self._check_installment_paid_email_sent("sam@fun-test.fr", order) # - An event has been created self.assertPaymentSuccessActivityLog(order) @@ -335,7 +365,21 @@ def test_payment_backend_base_do_on_payment_success_with_existing_billing_addres """ backend = TestBasePaymentBackend() owner = UserFactory(email="sam@fun-test.fr", language="en-us") - order = OrderFactory(owner=owner, state=enums.ORDER_STATE_SUBMITTED) + order = OrderFactory( + owner=owner, + product__price=Decimal("200.00"), + payment_schedule=[ + { + "id": "d9356dd7-19a6-4695-b18e-ad93af41424a", + "amount": "200.00", + "due_date": "2024-01-17", + "state": enums.PAYMENT_STATE_PENDING, + }, + ], + ) + CreditCardFactory( + owner=owner, is_main=True, initial_issuer_transaction_identifier="1" + ) billing_address = UserAddressFactory(owner=owner, is_reusable=True) payment = { "id": "pay_0", @@ -348,7 +392,9 @@ def test_payment_backend_base_do_on_payment_success_with_existing_billing_addres "last_name": billing_address.last_name, "postcode": billing_address.postcode, }, + "installment_id": "d9356dd7-19a6-4695-b18e-ad93af41424a", } + order.init_flow(billing_address=payment.get("billing_address")) # Only one address should exist self.assertEqual(Address.objects.count(), 1) @@ -372,13 +418,11 @@ def test_payment_backend_base_do_on_payment_success_with_existing_billing_addres invoice = order.main_invoice self.assertEqual(invoice.recipient_address, billing_address) - # - Order has been validated - self.assertEqual(order.state, "validated") + # - Order has been completed + self.assertEqual(order.state, enums.ORDER_STATE_COMPLETED) # - Email has been sent - self._check_order_validated_email_sent( - "sam@fun-test.fr", owner.get_full_name(), order - ) + self._check_installment_paid_email_sent("sam@fun-test.fr", order) def test_payment_backend_base_do_on_payment_failure(self): """ @@ -387,15 +431,27 @@ def test_payment_backend_base_do_on_payment_failure(self): order. """ backend = TestBasePaymentBackend() - order = OrderFactory(state=enums.ORDER_STATE_SUBMITTED) + order = OrderFactory( + state=enums.ORDER_STATE_PENDING, + payment_schedule=[ + { + "id": "d9356dd7-19a6-4695-b18e-ad93af41424a", + "amount": "200.00", + "due_date": "2024-01-17", + "state": enums.PAYMENT_STATE_PENDING, + }, + ], + ) - backend.call_do_on_payment_failure(order) + backend.call_do_on_payment_failure( + order, installment_id="d9356dd7-19a6-4695-b18e-ad93af41424a" + ) - # - Payment has failed gracefully and changed order state to pending - self.assertEqual(order.state, enums.ORDER_STATE_PENDING) + # - Payment has failed gracefully and changed order state to no payment + self.assertEqual(order.state, enums.ORDER_STATE_NO_PAYMENT) - # - No email has been sent - self.assertEqual(len(mail.outbox), 0) + # - An email should be sent mentioning the payment failure + self._check_installment_refused_email_sent(order.owner.email, order) # - An event has been created self.assertPaymentFailedActivityLog(order) @@ -408,7 +464,6 @@ def test_payment_backend_base_do_on_payment_failure_with_installment(self): """ backend = TestBasePaymentBackend() order = OrderFactory( - state=enums.ORDER_STATE_PENDING, payment_schedule=[ { "id": "d9356dd7-19a6-4695-b18e-ad93af41424a", @@ -436,6 +491,10 @@ def test_payment_backend_base_do_on_payment_failure_with_installment(self): }, ], ) + CreditCardFactory( + owner=order.owner, is_main=True, initial_issuer_transaction_identifier="1" + ) + order.init_flow(billing_address=BillingAddressDictFactory()) backend.call_do_on_payment_failure( order, installment_id=order.payment_schedule[0]["id"] @@ -449,34 +508,33 @@ def test_payment_backend_base_do_on_payment_failure_with_installment(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": enums.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": enums.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": enums.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": enums.PAYMENT_STATE_PENDING, }, ], ) - # - No email has been sent - self.assertEqual(len(mail.outbox), 0) - + # - An email should be sent mentioning the payment failure + self._check_installment_refused_email_sent(order.owner.email, order) # - An event has been created self.assertPaymentFailedActivityLog(order) @@ -487,21 +545,35 @@ def test_payment_backend_base_do_on_refund(self): transaction. """ backend = TestBasePaymentBackend() - order = OrderFactory(state=enums.ORDER_STATE_SUBMITTED) + order = OrderFactory( + payment_schedule=[ + { + "id": "d9356dd7-19a6-4695-b18e-ad93af41424a", + "amount": "200.00", + "due_date": "2024-01-17", + "state": enums.PAYMENT_STATE_PENDING, + }, + ] + ) billing_address = BillingAddressDictFactory() + CreditCardFactory( + owner=order.owner, is_main=True, initial_issuer_transaction_identifier="1" + ) + order.init_flow(billing_address=billing_address) # Create payment and register it payment = { "id": "pay_0", "amount": order.total, "billing_address": billing_address, + "installment_id": "d9356dd7-19a6-4695-b18e-ad93af41424a", } backend.call_do_on_payment_success(order, payment) - payment = Transaction.objects.get(reference="pay_0") + Transaction.objects.get(reference="pay_0") - # - Order has been validated - self.assertEqual(order.state, "validated") + # - Order has been completed + self.assertEqual(order.state, enums.ORDER_STATE_COMPLETED) # - Refund entirely the order backend.call_do_on_refund( @@ -532,7 +604,7 @@ def test_payment_backend_base_get_notification_url(self): ) @mock.patch( - "joanie.payment.backends.base.send_mail", + "joanie.core.utils.emails.send_mail", side_effect=smtplib.SMTPException("Error SMTPException"), ) @mock.patch.object(Logger, "error") @@ -542,12 +614,27 @@ def test_payment_backend_base_payment_success_email_failure( """Check error is raised if send_mails fails""" backend = TestBasePaymentBackend() owner = UserFactory(email="sam@fun-test.fr", username="Samantha") - order = OrderFactory(owner=owner, state=enums.ORDER_STATE_SUBMITTED) + order = OrderFactory( + owner=owner, + payment_schedule=[ + { + "id": "d9356dd7-19a6-4695-b18e-ad93af41424a", + "amount": "200.00", + "due_date": "2024-01-17", + "state": enums.PAYMENT_STATE_PENDING, + }, + ], + ) billing_address = BillingAddressDictFactory() + CreditCardFactory( + owner=order.owner, is_main=True, initial_issuer_transaction_identifier="1" + ) + order.init_flow(billing_address=billing_address) payment = { "id": "pay_0", "amount": order.total, "billing_address": billing_address, + "installment_id": "d9356dd7-19a6-4695-b18e-ad93af41424a", } backend.call_do_on_payment_success(order, payment) @@ -563,12 +650,12 @@ def test_payment_backend_base_payment_success_email_failure( self.assertIsNotNone(order.main_invoice) self.assertEqual(order.main_invoice.children.count(), 1) - # Order has been validated - self.assertEqual(order.state, "validated") + # - Order has been completed + self.assertEqual(order.state, enums.ORDER_STATE_COMPLETED) # No email has been sent self.assertEqual(len(mail.outbox), 0) - mock_logger.assert_called_once() + mock_logger.assert_called() self.assertEqual( mock_logger.call_args.args[0], "%s purchase order mail %s not send", @@ -590,12 +677,28 @@ def test_payment_backend_base_payment_success_email_with_fullname(self): last_name="Smith", language="en-us", ) - order = OrderFactory(owner=owner, state=enums.ORDER_STATE_SUBMITTED) + order = OrderFactory( + owner=owner, + product__price=Decimal("200.00"), + payment_schedule=[ + { + "id": "d9356dd7-19a6-4695-b18e-ad93af41424a", + "amount": "200.00", + "due_date": "2024-01-17", + "state": enums.PAYMENT_STATE_PENDING, + }, + ], + ) + CreditCardFactory( + owner=owner, is_main=True, initial_issuer_transaction_identifier="1" + ) billing_address = BillingAddressDictFactory() + order.init_flow(billing_address=billing_address) payment = { "id": "pay_0", "amount": order.total, "billing_address": billing_address, + "installment_id": "d9356dd7-19a6-4695-b18e-ad93af41424a", } backend.call_do_on_payment_success(order, payment) @@ -611,17 +714,14 @@ def test_payment_backend_base_payment_success_email_with_fullname(self): self.assertIsNotNone(order.main_invoice) self.assertEqual(order.main_invoice.children.count(), 1) - # - Order has been validated - self.assertEqual(order.state, "validated") + # - Order has been completed + self.assertEqual(order.state, enums.ORDER_STATE_COMPLETED) # - Email has been sent email_content = " ".join(mail.outbox[0].body.split()) - self.assertIn("Your order has been confirmed.", email_content) + self.assertIn("Your order is now fully paid!", email_content) self.assertIn("Hello Samantha Smith", email_content) - # - Check it's the right object - self.assertEqual(mail.outbox[0].subject, "Purchase order confirmed!") - def test_payment_backend_base_payment_success_email_language(self): """Check language of the user is taken into account for the email""" @@ -632,19 +732,41 @@ def test_payment_backend_base_payment_success_email_language(self): first_name="Dave", last_name="Bowman", ) - order = OrderFactory(owner=owner, state=enums.ORDER_STATE_SUBMITTED) + CreditCardFactory( + owner=owner, is_main=True, initial_issuer_transaction_identifier="1" + ) + product = ProductFactory(title="Product 1", price=Decimal("200.00")) + product.translations.create( + language_code="fr-fr", + title="Produit 1", + ) + order = OrderFactory( + owner=owner, + product=product, + payment_schedule=[ + { + "id": "d9356dd7-19a6-4695-b18e-ad93af41424a", + "amount": "200.00", + "due_date": "2024-01-17", + "state": enums.PAYMENT_STATE_PENDING, + }, + ], + ) billing_address = BillingAddressDictFactory() + order.init_flow(billing_address=billing_address) + order_total = order.total * 100 payment = { "id": "pay_0", - "amount": order.total, + "amount": order_total, "billing_address": billing_address, + "installment_id": "d9356dd7-19a6-4695-b18e-ad93af41424a", } backend.call_do_on_payment_success(order, payment) # - Payment transaction has been registered self.assertEqual( - Transaction.objects.filter(reference="pay_0", total=order.total).count(), + Transaction.objects.filter(reference="pay_0", total=order_total).count(), 1, ) @@ -653,14 +775,466 @@ def test_payment_backend_base_payment_success_email_language(self): self.assertIsNotNone(order.main_invoice) self.assertEqual(order.main_invoice.children.count(), 1) - # - Order has been validated - self.assertEqual(order.state, "validated") + # - Order has been completed + self.assertEqual(order.state, enums.ORDER_STATE_COMPLETED) # - Email has been sent email_content = " ".join(mail.outbox[0].body.split()) - self.assertIn("Votre commande a été confirmée.", email_content) - self.assertIn("Bonjour Dave Bowman", email_content) - self.assertNotIn("Your order has been confirmed.", email_content) + self.assertIn("Produit 1", email_content) + + def test_payment_backend_base_payment_success_installment_payment_mail_in_english( + self, + ): + """ + Check language used in the email according to the user's language preference. + """ + backend = TestBasePaymentBackend() + owner = UserFactory( + email="sam@fun-test.fr", + language="en-us", + first_name="John", + last_name="Doe", + ) + product = ProductFactory( + title="Product 1", + description="Product 1 description", + price=Decimal("1000.00"), + ) + product.translations.create( + language_code="fr-fr", + title="Produit 1", + ) + order = OrderFactory( + state=enums.ORDER_STATE_PENDING_PAYMENT, + owner=owner, + product=product, + payment_schedule=[ + { + "id": "d9356dd7-19a6-4695-b18e-ad93af41424a", + "amount": "200.00", + "due_date": "2024-01-17", + "state": enums.PAYMENT_STATE_PAID, + }, + { + "id": "d9356dd7-19a6-4695-b18e-ad93af41499a", + "amount": "300.00", + "due_date": "2024-02-17", + "state": enums.PAYMENT_STATE_PENDING, + }, + { + "id": "d9356dd7-19a6-4695-b18e-ad93af41477a", + "amount": "300.00", + "due_date": "2024-03-17", + "state": enums.PAYMENT_STATE_PENDING, + }, + { + "id": "d9356dd7-19a6-4695-b18e-ad93af41488a", + "amount": "200.00", + "due_date": "2024-04-17", + "state": enums.PAYMENT_STATE_PENDING, + }, + ], + ) + billing_address = BillingAddressDictFactory() + InvoiceFactory(order=order) + payment = { + "id": "pay_0", + "amount": 30000, + "billing_address": billing_address, + "installment_id": order.payment_schedule[1]["id"], + } + + backend.call_do_on_payment_success(order, payment) + + # - Order must be pending payment for other installments to pay + self.assertEqual(order.state, enums.ORDER_STATE_PENDING_PAYMENT) + + self.assertEqual( + mail.outbox[0].subject, + "Test Catalog - Product 1 - An installment has been successfully paid of 300.00 EUR", + ) + # - Email content is sent in English + email_content = " ".join(mail.outbox[0].body.split()) + self.assertIn("Hello John Doe", email_content) + self.assertIn("Product 1", email_content) + + def test_payment_backend_base_payment_success_installment_payment_mail_in_french( + self, + ): + """ + Check language used in the email according to the user's language preference. + """ + backend = TestBasePaymentBackend() + owner = UserFactory( + email="sam@fun-test.fr", + language="fr-fr", + first_name="John", + last_name="Doe", + ) + product = ProductFactory( + title="Product 1", + description="Product 1 description", + price=Decimal("1000.00"), + ) + product.translations.create( + language_code="fr-fr", + title="Produit 1", + ) + product.refresh_from_db() + order = OrderFactory( + state=enums.ORDER_STATE_PENDING_PAYMENT, + owner=owner, + product=product, + payment_schedule=[ + { + "id": "d9356dd7-19a6-4695-b18e-ad93af41424a", + "amount": "200.00", + "due_date": "2024-01-17", + "state": enums.PAYMENT_STATE_PAID, + }, + { + "id": "d9356dd7-19a6-4695-b18e-ad93af41499a", + "amount": "300.00", + "due_date": "2024-02-17", + "state": enums.PAYMENT_STATE_PENDING, + }, + { + "id": "d9356dd7-19a6-4695-b18e-ad93af41477a", + "amount": "300.00", + "due_date": "2024-03-17", + "state": enums.PAYMENT_STATE_PENDING, + }, + { + "id": "d9356dd7-19a6-4695-b18e-ad93af41488a", + "amount": "200.00", + "due_date": "2024-04-17", + "state": enums.PAYMENT_STATE_PENDING, + }, + ], + ) + billing_address = BillingAddressDictFactory() + InvoiceFactory(order=order) + payment = { + "id": "pay_0", + "amount": 30000, + "billing_address": billing_address, + "installment_id": order.payment_schedule[1]["id"], + } + + backend.call_do_on_payment_success(order, payment) + + # - Order must be pending payment for other installments to pay + self.assertEqual(order.state, enums.ORDER_STATE_PENDING_PAYMENT) + # - Check if some content is sent in French + email_content = " ".join(mail.outbox[0].body.split()) + self.assertIn("Produit 1", email_content) + + def test_payment_backend_base_payment_email_full_life_cycle_on_payment_schedule_events( + self, + ): + """ + The user gets an email for each installment paid. Once the order is validated ("PENDING") + he will get another email mentioning that his order is confirmed. + """ + backend = TestBasePaymentBackend() + order = OrderFactory( + state=enums.ORDER_STATE_PENDING_PAYMENT, + owner=UserFactory( + email="sam@fun-test.fr", + language="en-us", + first_name="John", + last_name="Doe", + ), + product=ProductFactory(title="Product 1", price=Decimal("1000.00")), + payment_schedule=[ + { + "id": "d9356dd7-19a6-4695-b18e-ad93af41424a", + "amount": "200.00", + "due_date": "2024-01-17", + "state": enums.PAYMENT_STATE_PENDING, + }, + { + "id": "d9356dd7-19a6-4695-b18e-ad93af41499a", + "amount": "300.00", + "due_date": "2024-02-17", + "state": enums.PAYMENT_STATE_PENDING, + }, + { + "id": "d9356dd7-19a6-4695-b18e-ad93af41477a", + "amount": "300.00", + "due_date": "2024-03-17", + "state": enums.PAYMENT_STATE_PENDING, + }, + { + "id": "d9356dd7-19a6-4695-b18e-ad93af41488a", + "amount": "200.00", + "due_date": "2024-04-17", + "state": enums.PAYMENT_STATE_PENDING, + }, + ], + ) + billing_address = BillingAddressDictFactory() + InvoiceFactory(order=order) + payment_0 = { + "id": "pay_0", + "amount": 20000, + "billing_address": billing_address, + "installment_id": order.payment_schedule[0]["id"], + } + + backend.call_do_on_payment_success(order, payment_0) + + # - Order must be pending payment for other installments to pay + self.assertEqual(order.state, enums.ORDER_STATE_PENDING_PAYMENT) + # Check the email sent on first payment to confirm installment payment + email_content = " ".join(mail.outbox[0].body.split()) + self.assertIn("Product 1", email_content) + self.assertIn("John Doe", email_content) + self.assertIn("200.00", email_content) + self.assertNotIn( + "you have paid all the installments successfully", email_content + ) + + mail.outbox.clear() + + payment_1 = { + "id": "pay_1", + "amount": 30000, + "billing_address": billing_address, + "installment_id": order.payment_schedule[1]["id"], + } + + backend.call_do_on_payment_success(order, payment_1) + + # Check the second email sent on second payment to confirm installment payment + email_content = " ".join(mail.outbox[0].body.split()) + self.assertIn("Product 1", email_content) + self.assertIn("300.00", email_content) + self.assertNotIn( + "you have paid all the installments successfully", email_content + ) + + mail.outbox.clear() + + payment_2 = { + "id": "pay_2", + "amount": 30000, + "billing_address": billing_address, + "installment_id": order.payment_schedule[2]["id"], + } + + backend.call_do_on_payment_success(order, payment_2) + + # Check the second email sent on third payment to confirm installment payment + email_content = " ".join(mail.outbox[0].body.split()) + self.assertIn("Product 1", email_content) + self.assertIn("300.00", email_content) + self.assertNotIn( + "you have paid all the installments successfully", email_content + ) + + mail.outbox.clear() + + payment_3 = { + "id": "pay_3", + "amount": 20000, + "billing_address": billing_address, + "installment_id": order.payment_schedule[3]["id"], + } + + backend.call_do_on_payment_success(order, payment_3) + + # Check the second email sent on fourth payment to confirm installment payment + email_content_2 = " ".join(mail.outbox[0].body.split()) + self.assertIn("Product 1", email_content_2) + self.assertIn("200.00", email_content_2) + self.assertIn("we have just debited the last installment", email_content_2) - # - Check it's the right object - self.assertEqual(mail.outbox[0].subject, "Commande confirmée !") + @override_settings( + LANGUAGES=( + ("en-us", ("English")), + ("fr-fr", ("French")), + ("de-de", ("German")), + ) + ) + def test_payment_backend_base_payment_fallback_language_in_email(self): + """ + The email must be sent into the user's preferred language. If the translation + of the product title exists, it should be in the preferred language of the user, else it + should use the fallback language that is english. + """ + backend = TestBasePaymentBackend() + product = ProductFactory(title="Product 1", price=Decimal("1000.00")) + product.translations.create(language_code="fr-fr", title="Produit 1") + order = OrderFactory( + product=product, + state=enums.PAYMENT_STATE_PENDING, + owner=UserFactory( + email="sam@fun-test.fr", + language="fr-fr", + first_name="John", + last_name="Doe", + ), + payment_schedule=[ + { + "id": "d9356dd7-19a6-4695-b18e-ad93af41424a", + "amount": "200.00", + "due_date": "2024-01-17", + "state": enums.PAYMENT_STATE_PAID, + }, + { + "id": "d9356dd7-19a6-4695-b18e-ad93af41499a", + "amount": "300.00", + "due_date": "2024-02-17", + "state": enums.PAYMENT_STATE_PENDING, + }, + { + "id": "d9356dd7-19a6-4695-b18e-ad93af41477a", + "amount": "300.00", + "due_date": "2024-03-17", + "state": enums.PAYMENT_STATE_PENDING, + }, + { + "id": "d9356dd7-19a6-4695-b18e-ad93af41488a", + "amount": "200.00", + "due_date": "2024-04-17", + "state": enums.PAYMENT_STATE_PENDING, + }, + ], + ) + InvoiceFactory(order=order) + billing_address = BillingAddressDictFactory() + payment = { + "id": "pay_0", + "amount": 20000, + "billing_address": billing_address, + "installment_id": order.payment_schedule[1]["id"], + } + + backend.call_do_on_payment_success(order, payment) + + email_content = " ".join(mail.outbox[0].body.split()) + self.assertIn("Produit 1", email_content) + mail.outbox.clear() + + # Change the preferred language of the user to english + order.owner.language = "en-us" + order.owner.save() + + payment_1 = { + "id": "pay_1", + "amount": 30000, + "billing_address": billing_address, + "installment_id": order.payment_schedule[2]["id"], + } + + backend.call_do_on_payment_success(order, payment_1) + + email_content = " ".join(mail.outbox[0].body.split()) + self.assertIn("Product 1", email_content) + mail.outbox.clear() + + # Change the preferred language of the user to German (should use the fallback) + order.owner.language = "de-de" + order.owner.save() + + payment_2 = { + "id": "pay_2", + "amount": 20000, + "billing_address": billing_address, + "installment_id": order.payment_schedule[3]["id"], + } + + backend.call_do_on_payment_success(order, payment_2) + # Check the content uses the fallback language (english) + # because there is no translation in german for the product title + email_content = " ".join(mail.outbox[0].body.split()) + self.assertIn("Product 1", email_content) + + @override_settings( + LANGUAGES=( + ("en-us", ("English")), + ("fr-fr", ("French")), + ("de-de", ("German")), + ) + ) + def test_payment_backend_base_mail_sent_on_installment_payment_failure_in_french( + self, + ): + """ + When an installment debit has been refused an email should be sent + with the information about the payment failure in the current language + of the user. + """ + backend = TestBasePaymentBackend() + product = ProductFactory(title="Product 1", price=Decimal("149.00")) + product.translations.create(language_code="fr-fr", title="Produit 1") + order = OrderFactory( + state=enums.ORDER_STATE_PENDING, + product=product, + owner=UserFactory( + email="sam@fun-test.fr", + language="fr-fr", + first_name="John", + last_name="Doe", + ), + payment_schedule=[ + { + "id": "3d0efbff-6b09-4fb4-82ce-54b6bb57a809", + "amount": "149.00", + "due_date": "2024-08-07", + "state": enums.PAYMENT_STATE_PENDING, + }, + ], + ) + + backend.call_do_on_payment_failure( + order, installment_id="3d0efbff-6b09-4fb4-82ce-54b6bb57a809" + ) + + self.assertEqual(order.state, enums.ORDER_STATE_NO_PAYMENT) + self._check_installment_refused_email_sent("sam@fun-test.fr", order) + + @override_settings( + LANGUAGES=( + ("en-us", ("English")), + ("fr-fr", ("French")), + ("de-de", ("German")), + ) + ) + def test_payment_backend_base_mail_sent_on_installment_payment_failure_use_fallback_language( + self, + ): + """ + If the translation of the product title does not exists, it should use the fallback + language that is english. + """ + backend = TestBasePaymentBackend() + product = ProductFactory(title="Product 1", price=Decimal("150.00")) + # Create on purpose another translation of the product title that is not the user language + product.translations.create(language_code="fr-fr", title="Produit 1") + order = OrderFactory( + state=enums.ORDER_STATE_PENDING, + product=product, + owner=UserFactory( + email="sam@fun-test.fr", + language="de-de", + first_name="John", + last_name="Doe", + ), + payment_schedule=[ + { + "id": "3d0efbff-6b09-4fb4-82ce-54b6bb57a809", + "amount": "150.00", + "due_date": "2024-08-07", + "state": enums.PAYMENT_STATE_PENDING, + }, + ], + ) + + backend.call_do_on_payment_failure( + order, installment_id="3d0efbff-6b09-4fb4-82ce-54b6bb57a809" + ) + + self.assertEqual(order.state, enums.ORDER_STATE_NO_PAYMENT) + self._check_installment_refused_email_sent("sam@fun-test.fr", order) diff --git a/src/backend/joanie/tests/payment/test_backend_dummy_payment.py b/src/backend/joanie/tests/payment/test_backend_dummy_payment.py index 8f8d0cbc4..0ee081c5f 100644 --- a/src/backend/joanie/tests/payment/test_backend_dummy_payment.py +++ b/src/backend/joanie/tests/payment/test_backend_dummy_payment.py @@ -2,6 +2,7 @@ import json import re +from decimal import Decimal as D from logging import Logger from unittest import mock @@ -12,14 +13,18 @@ from rest_framework.test import APIRequestFactory from joanie.core.enums import ( + ORDER_STATE_COMPLETED, + ORDER_STATE_NO_PAYMENT, ORDER_STATE_PENDING, ORDER_STATE_PENDING_PAYMENT, - ORDER_STATE_SUBMITTED, - ORDER_STATE_VALIDATED, PAYMENT_STATE_PAID, PAYMENT_STATE_PENDING, ) -from joanie.core.factories import OrderFactory, ProductFactory, UserFactory +from joanie.core.factories import ( + OrderFactory, + OrderGeneratorFactory, + UserFactory, +) from joanie.payment.backends.base import BasePaymentBackend from joanie.payment.backends.dummy import DummyPaymentBackend from joanie.payment.exceptions import ( @@ -28,7 +33,6 @@ RefundPaymentFailed, RegisterPaymentFailed, ) -from joanie.payment.factories import BillingAddressDictFactory from joanie.payment.models import CreditCard from joanie.tests.payment.base_payment import BasePaymentTestCase @@ -63,10 +67,15 @@ def test_payment_backend_dummy_create_payment(self): which aims to be embedded into the api response. """ backend = DummyPaymentBackend() - order = OrderFactory() - billing_address = BillingAddressDictFactory() - payment_payload = backend.create_payment(order, billing_address) - payment_id = f"pay_{order.id}" + order = OrderGeneratorFactory(state=ORDER_STATE_PENDING) + billing_address = order.main_invoice.recipient_address.to_dict() + first_installment = order.payment_schedule[0] + installment_id = str(first_installment.get("id")) + payment_id = f"pay_{installment_id}" + + payment_payload = backend.create_payment( + order, first_installment, billing_address + ) self.assertEqual( payment_payload, @@ -78,14 +87,18 @@ def test_payment_backend_dummy_create_payment(self): ) payment = cache.get(payment_id) + self.assertEqual( payment, { "id": payment_id, - "amount": int(order.total * 100), + "amount": first_installment.get("amount").sub_units, "billing_address": billing_address, "notification_url": "https://example.com/api/v1.0/payments/notifications", - "metadata": {"order_id": str(order.id)}, + "metadata": { + "order_id": str(order.id), + "installment_id": str(first_installment.get("id")), + }, }, ) @@ -96,39 +109,13 @@ def test_payment_backend_dummy_create_payment_with_installment(self): which aims to be embedded into the api response. """ backend = DummyPaymentBackend() - order = OrderFactory( - payment_schedule=[ - { - "id": "d9356dd7-19a6-4695-b18e-ad93af41424a", - "amount": "200.00", - "due_date": "2024-01-17", - "state": PAYMENT_STATE_PENDING, - }, - { - "id": "1932fbc5-d971-48aa-8fee-6d637c3154a5", - "amount": "300.00", - "due_date": "2024-02-17", - "state": PAYMENT_STATE_PENDING, - }, - { - "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, - }, - ], - ) - billing_address = BillingAddressDictFactory() + order = OrderGeneratorFactory(state=ORDER_STATE_PENDING) + billing_address = order.main_invoice.recipient_address.to_dict() payment_payload = backend.create_payment( - order, billing_address, installment=order.payment_schedule[0] + order, order.payment_schedule[0], billing_address ) - payment_id = f"pay_{order.id}" + installment_id = str(order.payment_schedule[0].get("id")) + payment_id = f"pay_{installment_id}" self.assertEqual( payment_payload, @@ -144,12 +131,12 @@ def test_payment_backend_dummy_create_payment_with_installment(self): payment, { "id": payment_id, - "amount": 20000, + "amount": order.payment_schedule[0]["amount"].sub_units, "billing_address": billing_address, "notification_url": "https://example.com/api/v1.0/payments/notifications", "metadata": { "order_id": str(order.id), - "installment_id": order.payment_schedule[0]["id"], + "installment_id": str(order.payment_schedule[0]["id"]), }, }, ) @@ -160,31 +147,32 @@ def test_payment_backend_dummy_create_payment_with_installment(self): "handle_notification", side_effect=DummyPaymentBackend().handle_notification, ) - @override_settings(JOANIE_CATALOG_NAME="Test Catalog") - @override_settings(JOANIE_CATALOG_BASE_URL="https://richie.education") + @override_settings( + JOANIE_PAYMENT_SCHEDULE_LIMITS={5: (100,)}, + DEFAULT_CURRENCY="EUR", + JOANIE_CATALOG_NAME="Test Catalog", + JOANIE_CATALOG_BASE_URL="https://richie.education", + ) def test_payment_backend_dummy_create_one_click_payment( self, mock_handle_notification, mock_logger ): """ Dummy backend `one_click_payment` calls the `create_payment` method then after - we trigger the `handle_notification` with payment info to validate the order. + we trigger the `handle_notification` with payment info to complete the order. It returns payment information with `is_paid` property sets to True to simulate that a one click payment has succeeded. """ backend = DummyPaymentBackend() - owner = UserFactory( - email="sam@fun-test.fr", - language="en-us", - username="Samantha", - first_name="", - last_name="", - ) - order = OrderFactory(owner=owner, state=ORDER_STATE_SUBMITTED) - billing_address = BillingAddressDictFactory() - payment_id = f"pay_{order.id}" + owner = UserFactory(language="en-us") + order = OrderGeneratorFactory(state=ORDER_STATE_PENDING, owner=owner) + billing_address = order.main_invoice.recipient_address.to_dict() + installment_id = str(order.payment_schedule[0].get("id")) + payment_id = f"pay_{installment_id}" - payment_payload = backend.create_one_click_payment(order, billing_address) + payment_payload = backend.create_one_click_payment( + order, order.payment_schedule[0], order.credit_card.token, billing_address + ) self.assertEqual( payment_payload, @@ -208,21 +196,25 @@ def test_payment_backend_dummy_create_one_click_payment( payment, { "id": payment_id, - "amount": int(order.total * 100), + "amount": int(order.payment_schedule[0]["amount"] * 100), "billing_address": billing_address, + "credit_card_token": order.credit_card.token, "notification_url": "https://example.com/api/v1.0/payments/notifications", - "metadata": {"order_id": str(order.id)}, + "metadata": { + "order_id": str(order.id), + "installment_id": str(order.payment_schedule[0]["id"]), + }, }, ) mock_handle_notification.assert_called_once() order.refresh_from_db() - self.assertEqual(order.state, ORDER_STATE_VALIDATED) + self.assertEqual(order.state, ORDER_STATE_COMPLETED) # check email has been sent - self._check_order_validated_email_sent("sam@fun-test.fr", "Samantha", order) + self._check_installment_paid_email_sent(order.owner.email, order) mock_logger.assert_called_with( - "Mail is sent to %s from dummy payment", "sam@fun-test.fr" + "Mail is sent to %s from dummy payment", order.owner.email ) @mock.patch.object(Logger, "info") @@ -244,48 +236,14 @@ def test_payment_backend_dummy_create_one_click_payment_with_installment( """ backend = DummyPaymentBackend() - owner = UserFactory( - email="sam@fun-test.fr", - language="en-us", - username="Samantha", - first_name="", - last_name="", - ) - order = OrderFactory( - owner=owner, - state=ORDER_STATE_PENDING, - payment_schedule=[ - { - "id": "d9356dd7-19a6-4695-b18e-ad93af41424a", - "amount": "200.00", - "due_date": "2024-01-17", - "state": PAYMENT_STATE_PENDING, - }, - { - "id": "1932fbc5-d971-48aa-8fee-6d637c3154a5", - "amount": "300.00", - "due_date": "2024-02-17", - "state": PAYMENT_STATE_PENDING, - }, - { - "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, - }, - ], - ) - billing_address = BillingAddressDictFactory() - payment_id = f"pay_{order.id}" + owner = UserFactory(language="en-us") + order = OrderGeneratorFactory(state=ORDER_STATE_PENDING, owner=owner) + billing_address = order.main_invoice.recipient_address.to_dict() + installment_id = str(order.payment_schedule[0].get("id")) + payment_id = f"pay_{installment_id}" payment_payload = backend.create_one_click_payment( - order, billing_address, installment=order.payment_schedule[0] + order, order.payment_schedule[0], order.credit_card.token, billing_address ) self.assertEqual( @@ -304,18 +262,20 @@ def test_payment_backend_dummy_create_one_click_payment_with_installment( format="json", ) request.data = json.loads(request.body.decode("utf-8")) + backend.handle_notification(request) payment = cache.get(payment_id) self.assertEqual( payment, { "id": payment_id, - "amount": 20000, + "amount": order.payment_schedule[0]["amount"].sub_units, "billing_address": billing_address, + "credit_card_token": order.credit_card.token, "notification_url": "https://example.com/api/v1.0/payments/notifications", "metadata": { "order_id": str(order.id), - "installment_id": order.payment_schedule[0]["id"], + "installment_id": str(order.payment_schedule[0]["id"]), }, }, ) @@ -323,40 +283,17 @@ def test_payment_backend_dummy_create_one_click_payment_with_installment( mock_handle_notification.assert_called_once() order.refresh_from_db() self.assertEqual(order.state, ORDER_STATE_PENDING_PAYMENT) - self.assertEqual( - order.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_PENDING, - }, - { - "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, - }, - ], - ) + for installment in order.payment_schedule: + if installment["id"] == order.payment_schedule[0]["id"]: + self.assertEqual(installment["state"], PAYMENT_STATE_PAID) + else: + self.assertEqual(installment["state"], PAYMENT_STATE_PENDING) + # check email has been sent - self._check_order_validated_email_sent("sam@fun-test.fr", "Samantha", order) + self._check_installment_paid_email_sent(order.owner.email, order) mock_logger.assert_called_with( - "Mail is sent to %s from dummy payment", "sam@fun-test.fr" + "Mail is sent to %s from dummy payment", order.owner.email ) def test_payment_backend_dummy_handle_notification_unknown_resource(self): @@ -385,9 +322,12 @@ def test_payment_backend_dummy_handle_notification_payment_with_missing_state(se backend = DummyPaymentBackend() # Create a payment - order = OrderFactory() - billing_address = BillingAddressDictFactory() - payment_id = backend.create_payment(order, billing_address)["payment_id"] + order = OrderGeneratorFactory(state=ORDER_STATE_PENDING) + billing_address = order.main_invoice.recipient_address.to_dict() + first_installment = order.payment_schedule[0] + payment_id = backend.create_payment(order, first_installment, billing_address)[ + "payment_id" + ] # Notify a payment with a no state request = APIRequestFactory().post( @@ -413,9 +353,12 @@ def test_payment_backend_dummy_handle_notification_payment_with_bad_payload(self backend = DummyPaymentBackend() # Create a payment - order = OrderFactory() - billing_address = BillingAddressDictFactory() - payment_id = backend.create_payment(order, billing_address)["payment_id"] + order = OrderGeneratorFactory(state=ORDER_STATE_PENDING) + billing_address = order.main_invoice.recipient_address.to_dict() + first_installment = order.payment_schedule[0] + payment_id = backend.create_payment(order, first_installment, billing_address)[ + "payment_id" + ] # Notify a payment with a no state request = APIRequestFactory().post( @@ -439,14 +382,16 @@ def test_payment_backend_dummy_handle_notification_payment_failed( ): """ When backend is notified that a payment failed, the generic method - _do_on_paymet_failure should be called + `_do_on_payment_failure` should be called """ backend = DummyPaymentBackend() # Create a payment - order = OrderFactory(state=ORDER_STATE_SUBMITTED) - billing_address = BillingAddressDictFactory() - payment_id = backend.create_payment(order, billing_address)["payment_id"] + order = OrderGeneratorFactory(state=ORDER_STATE_PENDING) + first_installment = order.payment_schedule[0] + payment_id = backend.create_payment( + order, first_installment, order.main_invoice.recipient_address + )["payment_id"] # Notify that payment failed request = APIRequestFactory().post( @@ -458,9 +403,11 @@ def test_payment_backend_dummy_handle_notification_payment_failed( backend.handle_notification(request) order.refresh_from_db() - self.assertEqual(order.state, ORDER_STATE_SUBMITTED) + self.assertEqual(order.state, ORDER_STATE_PENDING) - mock_payment_failure.assert_called_once_with(order, installment_id=None) + mock_payment_failure.assert_called_once_with( + order, installment_id=str(first_installment["id"]) + ) @mock.patch.object(BasePaymentBackend, "_do_on_payment_failure") def test_payment_backend_dummy_handle_notification_payment_failed_with_installment( @@ -468,43 +415,15 @@ def test_payment_backend_dummy_handle_notification_payment_failed_with_installme ): """ When backend is notified that a payment failed, the generic method - _do_on_paymet_failure should be called + `_do_on_payment_failure` should be called """ backend = DummyPaymentBackend() # Create a payment - order = OrderFactory( - state=ORDER_STATE_SUBMITTED, - payment_schedule=[ - { - "id": "d9356dd7-19a6-4695-b18e-ad93af41424a", - "amount": "200.00", - "due_date": "2024-01-17", - "state": PAYMENT_STATE_PENDING, - }, - { - "id": "1932fbc5-d971-48aa-8fee-6d637c3154a5", - "amount": "300.00", - "due_date": "2024-02-17", - "state": PAYMENT_STATE_PENDING, - }, - { - "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, - }, - ], - ) - billing_address = BillingAddressDictFactory() + order = OrderGeneratorFactory(state=ORDER_STATE_PENDING) + billing_address = order.main_invoice.recipient_address.to_dict() payment_id = backend.create_payment( - order, billing_address, installment=order.payment_schedule[0] + order, order.payment_schedule[0], billing_address )["payment_id"] # Notify that payment failed @@ -517,10 +436,10 @@ def test_payment_backend_dummy_handle_notification_payment_failed_with_installme backend.handle_notification(request) order.refresh_from_db() - self.assertEqual(order.state, ORDER_STATE_SUBMITTED) + self.assertEqual(order.state, ORDER_STATE_PENDING) mock_payment_failure.assert_called_once_with( - order, installment_id="d9356dd7-19a6-4695-b18e-ad93af41424a" + order, installment_id=order.payment_schedule[0]["id"] ) @mock.patch.object(BasePaymentBackend, "_do_on_payment_success") @@ -534,9 +453,11 @@ def test_payment_backend_dummy_handle_notification_payment_success( backend = DummyPaymentBackend() # Create a payment - order = OrderFactory() - billing_address = BillingAddressDictFactory() - payment_id = backend.create_payment(order, billing_address)["payment_id"] + order = OrderGeneratorFactory(state=ORDER_STATE_PENDING) + first_installment = order.payment_schedule[0] + payment_id = backend.create_payment( + order, first_installment, order.main_invoice.recipient_address + )["payment_id"] # Notify that a payment succeeded request = APIRequestFactory().post( @@ -550,9 +471,9 @@ def test_payment_backend_dummy_handle_notification_payment_success( payment = { "id": payment_id, - "amount": order.total, - "billing_address": billing_address, - "installment_id": None, + "amount": first_installment["amount"], + "billing_address": order.main_invoice.recipient_address, + "installment_id": str(first_installment["id"]), } mock_payment_success.assert_called_once_with(order, payment) @@ -568,37 +489,10 @@ def test_payment_backend_dummy_handle_notification_payment_success_with_installm backend = DummyPaymentBackend() # Create a payment - order = OrderFactory( - payment_schedule=[ - { - "id": "d9356dd7-19a6-4695-b18e-ad93af41424a", - "amount": "200.00", - "due_date": "2024-01-17", - "state": PAYMENT_STATE_PENDING, - }, - { - "id": "1932fbc5-d971-48aa-8fee-6d637c3154a5", - "amount": "300.00", - "due_date": "2024-02-17", - "state": PAYMENT_STATE_PENDING, - }, - { - "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, - }, - ] - ) - billing_address = BillingAddressDictFactory() + order = OrderGeneratorFactory(state=ORDER_STATE_PENDING) + billing_address = order.main_invoice.recipient_address.to_dict() payment_id = backend.create_payment( - order, billing_address, installment=order.payment_schedule[0] + order, order.payment_schedule[0], billing_address )["payment_id"] # Notify that a payment succeeded @@ -613,9 +507,9 @@ def test_payment_backend_dummy_handle_notification_payment_success_with_installm payment = { "id": payment_id, - "amount": 200, + "amount": order.payment_schedule[0]["amount"].as_decimal(), "billing_address": billing_address, - "installment_id": "d9356dd7-19a6-4695-b18e-ad93af41424a", + "installment_id": str(order.payment_schedule[0]["id"]), } mock_payment_success.assert_called_once_with(order, payment) @@ -630,9 +524,12 @@ def test_payment_backend_dummy_handle_notification_refund_with_missing_amount( backend = DummyPaymentBackend() # Create a payment - order = OrderFactory() - billing_address = BillingAddressDictFactory() - payment_id = backend.create_payment(order, billing_address)["payment_id"] + order = OrderGeneratorFactory(state=ORDER_STATE_PENDING) + billing_address = order.main_invoice.recipient_address.to_dict() + first_installment = order.payment_schedule[0] + payment_id = backend.create_payment(order, first_installment, billing_address)[ + "payment_id" + ] # Notify that payment succeeded # Notify that payment has been refund @@ -659,10 +556,13 @@ def test_payment_backend_dummy_handle_notification_refund_with_invalid_amount( backend = DummyPaymentBackend() # Create a payment - order = OrderFactory() - billing_address = BillingAddressDictFactory() - payment_id = backend.create_payment(order, billing_address)["payment_id"] - + order = OrderGeneratorFactory(state=ORDER_STATE_PENDING) + billing_address = order.main_invoice.recipient_address.to_dict() + first_installment = order.payment_schedule[0] + first_installment_amount = first_installment.get("amount") + payment_id = backend.create_payment(order, first_installment, billing_address)[ + "payment_id" + ] # Notify that payment has been refunded with an amount higher than # product price request = APIRequestFactory().post( @@ -670,7 +570,7 @@ def test_payment_backend_dummy_handle_notification_refund_with_invalid_amount( data={ "id": payment_id, "type": "refund", - "amount": int(order.total * 100) + 1, + "amount": int(first_installment_amount * 100) + 1, }, format="json", ) @@ -679,9 +579,10 @@ def test_payment_backend_dummy_handle_notification_refund_with_invalid_amount( with self.assertRaises(RefundPaymentFailed) as context: backend.handle_notification(request) + payment_amount = D(f"{first_installment_amount:.2f}") self.assertEqual( str(context.exception), - f"Refund amount is greater than payment amount ({order.total})", + f"Refund amount is greater than payment amount ({payment_amount})", ) def test_payment_backend_dummy_handle_notification_refund_unknown_payment(self): @@ -693,9 +594,12 @@ def test_payment_backend_dummy_handle_notification_refund_unknown_payment(self): request_factory = APIRequestFactory() # Create a payment - order = OrderFactory() - billing_address = BillingAddressDictFactory() - payment_id = backend.create_payment(order, billing_address)["payment_id"] + order = OrderGeneratorFactory(state=ORDER_STATE_PENDING) + billing_address = order.main_invoice.recipient_address.to_dict() + first_installment = order.payment_schedule[0] + payment_id = backend.create_payment(order, first_installment, billing_address)[ + "payment_id" + ] # Notify that payment has been refunded request = request_factory.post( @@ -703,7 +607,7 @@ def test_payment_backend_dummy_handle_notification_refund_unknown_payment(self): data={ "id": payment_id, "type": "refund", - "amount": int(order.total * 100), + "amount": int(first_installment.get("amount") * 100), }, format="json", ) @@ -726,9 +630,15 @@ def test_payment_backend_dummy_handle_notification_refund(self, mock_refund): request_factory = APIRequestFactory() # Create a payment - order = OrderFactory(state=ORDER_STATE_SUBMITTED) - billing_address = BillingAddressDictFactory() - payment_id = backend.create_payment(order, billing_address)["payment_id"] + order = OrderGeneratorFactory( + state=ORDER_STATE_PENDING, + # This price causes rounding issues if Money is not used + product__price=D("902.80"), + ) + first_installment = order.payment_schedule[0] + payment_id = backend.create_payment( + order, first_installment, order.main_invoice.recipient_address + )["payment_id"] # Notify that payment has been paid request = request_factory.post( @@ -737,6 +647,7 @@ def test_payment_backend_dummy_handle_notification_refund(self, mock_refund): "id": payment_id, "type": "payment", "state": "success", + "installment_id": first_installment["id"], }, format="json", ) @@ -749,7 +660,8 @@ def test_payment_backend_dummy_handle_notification_refund(self, mock_refund): data={ "id": payment_id, "type": "refund", - "amount": int(order.total * 100), + "amount": first_installment["amount"].sub_units, + "installment_id": first_installment["id"], }, format="json", ) @@ -760,7 +672,7 @@ def test_payment_backend_dummy_handle_notification_refund(self, mock_refund): args = mock_refund.call_args.kwargs self.assertEqual(len(args), 3) - self.assertEqual(args["amount"], order.total) + self.assertEqual(float(args["amount"]), float(first_installment["amount"])) self.assertEqual(args["invoice"], order.main_invoice) self.assertIsNotNone(re.fullmatch(r"ref_\d{10}", args["refund_reference"])) @@ -782,12 +694,14 @@ def test_payment_backend_dummy_abort_payment(self): """ backend = DummyPaymentBackend() - order = OrderFactory(product=ProductFactory()) - billing_address = BillingAddressDictFactory() - request = APIRequestFactory().post(path="/") + order = OrderGeneratorFactory(state=ORDER_STATE_PENDING) # Create a payment - payment_id = backend.create_payment(order, billing_address)["payment_id"] + payment_id = backend.create_payment( + order, + order.payment_schedule[0], + order.main_invoice.recipient_address.to_dict(), + )["payment_id"] self.assertIsNotNone(cache.get(payment_id)) @@ -836,3 +750,43 @@ def test_payment_backend_dummy_tokenize_card(self): self.assertEqual(credit_card.token, f"card_{user.id}") self.assertEqual(credit_card.payment_provider, backend.name) + + @mock.patch.object(Logger, "info") + @mock.patch.object(BasePaymentBackend, "_send_mail_refused_debit") + def test_payment_backend_dummy_handle_notification_payment_failed_should_send_mail_to_user( + self, mock_send_mail_refused_debit, mock_logger + ): + """ + When backend is notified that a payment failed, the generic method + `_do_on_payment_failure` should be called and it should call also + the method that sends the email to the user. + """ + backend = DummyPaymentBackend() + + # Create a payment + order = OrderGeneratorFactory(state=ORDER_STATE_PENDING) + first_installment = order.payment_schedule[0] + payment_id = backend.create_payment( + order, first_installment, order.main_invoice.recipient_address + )["payment_id"] + + # Notify that payment failed + request = APIRequestFactory().post( + reverse("payment_webhook"), + data={"id": payment_id, "type": "payment", "state": "failed"}, + format="json", + ) + request.data = json.loads(request.body.decode("utf-8")) + + backend.handle_notification(request) + order.refresh_from_db() + + self.assertEqual(order.state, ORDER_STATE_NO_PAYMENT) + + mock_send_mail_refused_debit.assert_called_once_with( + order, str(first_installment["id"]) + ) + + mock_logger.assert_called_with( + "Mail is sent to %s from dummy payment", order.owner.email + ) diff --git a/src/backend/joanie/tests/payment/test_backend_lyra.py b/src/backend/joanie/tests/payment/test_backend_lyra.py index b543b6da8..67ed4d4e7 100644 --- a/src/backend/joanie/tests/payment/test_backend_lyra.py +++ b/src/backend/joanie/tests/payment/test_backend_lyra.py @@ -19,10 +19,10 @@ ORDER_STATE_PENDING, ORDER_STATE_PENDING_PAYMENT, PAYMENT_STATE_PAID, - PAYMENT_STATE_PENDING, ) from joanie.core.factories import ( OrderFactory, + OrderGeneratorFactory, ProductFactory, UserAddressFactory, UserFactory, @@ -33,6 +33,7 @@ ParseNotificationFailed, PaymentProviderAPIException, RegisterPaymentFailed, + TokenizationCardFailed, ) from joanie.payment.factories import CreditCardFactory from joanie.payment.models import CreditCard, Transaction @@ -116,10 +117,9 @@ def test_payment_backend_lyra_create_payment_server_request_exception(self): is raised. """ backend = LyraBackend(self.configuration) - owner = UserFactory(email="john.doe@acme.org") - product = ProductFactory(price=D("123.45")) - order = OrderFactory(owner=owner, product=product) - billing_address = UserAddressFactory(owner=owner) + order = OrderGeneratorFactory(state=ORDER_STATE_PENDING) + billing_address = order.main_invoice.recipient_address + first_installment = order.payment_schedule[0] responses.add( responses.POST, @@ -131,7 +131,7 @@ def test_payment_backend_lyra_create_payment_server_request_exception(self): self.assertRaises(PaymentProviderAPIException) as context, self.assertLogs() as logger, ): - backend.create_payment(order, billing_address) + backend.create_payment(order, first_installment, billing_address) self.assertEqual( str(context.exception), @@ -168,10 +168,9 @@ def test_payment_backend_lyra_create_payment_server_error(self): with some information about the source of the error. """ backend = LyraBackend(self.configuration) - owner = UserFactory(email="john.doe@acme.org") - product = ProductFactory(price=D("123.45")) - order = OrderFactory(owner=owner, product=product) - billing_address = UserAddressFactory(owner=owner) + order = OrderGeneratorFactory(state=ORDER_STATE_PENDING) + billing_address = order.main_invoice.recipient_address + first_installment = order.payment_schedule[0] responses.add( responses.POST, @@ -184,7 +183,7 @@ def test_payment_backend_lyra_create_payment_server_error(self): self.assertRaises(PaymentProviderAPIException) as context, self.assertLogs() as logger, ): - backend.create_payment(order, billing_address) + backend.create_payment(order, first_installment, billing_address) self.assertEqual( str(context.exception), @@ -219,6 +218,7 @@ def test_payment_backend_lyra_create_payment_server_error(self): ] self.assertLogsEquals(logger.records, expected_logs) + @override_settings(JOANIE_PAYMENT_SCHEDULE_LIMITS={0: (30, 70)}) @responses.activate(assert_all_requests_are_fired=True) def test_payment_backend_lyra_create_payment_failed(self): """ @@ -226,10 +226,12 @@ def test_payment_backend_lyra_create_payment_failed(self): if the payment failed. """ backend = LyraBackend(self.configuration) - owner = UserFactory(email="john.doe@acme.org") - product = ProductFactory(price=D("123.45")) - order = OrderFactory(owner=owner, product=product) - billing_address = UserAddressFactory(owner=owner) + order = OrderGeneratorFactory( + state=ORDER_STATE_PENDING, + product__price=D("123.45"), + ) + billing_address = order.main_invoice.recipient_address + first_installment = order.payment_schedule[0] with self.open("lyra/responses/create_payment_failed.json") as file: json_response = json.loads(file.read()) @@ -249,11 +251,11 @@ def test_payment_backend_lyra_create_payment_failed(self): ), responses.matchers.json_params_matcher( { - "amount": 12345, + "amount": 3704, "currency": "EUR", "customer": { - "email": "john.doe@acme.org", - "reference": str(owner.id), + "email": order.owner.email, + "reference": str(order.owner.id), "billingDetails": { "firstName": billing_address.first_name, "lastName": billing_address.last_name, @@ -261,14 +263,17 @@ def test_payment_backend_lyra_create_payment_failed(self): "zipCode": billing_address.postcode, "city": billing_address.city, "country": billing_address.country.code, - "language": owner.language, + "language": order.owner.language, }, "shippingDetails": { "shippingMethod": "DIGITAL_GOOD", }, }, "orderId": str(order.id), - "formAction": "ASK_REGISTER_PAY", + "metadata": { + "installment_id": str(first_installment.get("id")) + }, + "formAction": "REGISTER_PAY", "ipnTargetUrl": "https://example.com/api/v1.0/payments/notifications", } ), @@ -281,7 +286,7 @@ def test_payment_backend_lyra_create_payment_failed(self): self.assertRaises(PaymentProviderAPIException) as context, self.assertLogs() as logger, ): - backend.create_payment(order, billing_address) + backend.create_payment(order, first_installment, billing_address) self.assertEqual( str(context.exception), @@ -312,16 +317,19 @@ def test_payment_backend_lyra_create_payment_failed(self): ] self.assertLogsEquals(logger.records, expected_logs) + @override_settings(JOANIE_PAYMENT_SCHEDULE_LIMITS={0: (30, 70)}) @responses.activate(assert_all_requests_are_fired=True) def test_payment_backend_lyra_create_payment_accepted(self): """ When backend creates a payment, it should return a form token. """ backend = LyraBackend(self.configuration) - owner = UserFactory(email="john.doe@acme.org") - product = ProductFactory(price=D("123.45")) - order = OrderFactory(owner=owner, product=product) - billing_address = UserAddressFactory(owner=owner) + order = OrderGeneratorFactory( + state=ORDER_STATE_PENDING, + product__price=D("123.45"), + ) + billing_address = order.main_invoice.recipient_address + first_installment = order.payment_schedule[0] with self.open("lyra/responses/create_payment.json") as file: json_response = json.loads(file.read()) @@ -341,11 +349,11 @@ def test_payment_backend_lyra_create_payment_accepted(self): ), responses.matchers.json_params_matcher( { - "amount": 12345, + "amount": 3704, "currency": "EUR", "customer": { - "email": "john.doe@acme.org", - "reference": str(owner.id), + "email": order.owner.email, + "reference": str(order.owner.id), "billingDetails": { "firstName": billing_address.first_name, "lastName": billing_address.last_name, @@ -353,14 +361,17 @@ def test_payment_backend_lyra_create_payment_accepted(self): "zipCode": billing_address.postcode, "city": billing_address.city, "country": billing_address.country.code, - "language": owner.language, + "language": order.owner.language, }, "shippingDetails": { "shippingMethod": "DIGITAL_GOOD", }, }, "orderId": str(order.id), - "formAction": "ASK_REGISTER_PAY", + "metadata": { + "installment_id": str(first_installment.get("id")) + }, + "formAction": "REGISTER_PAY", "ipnTargetUrl": "https://example.com/api/v1.0/payments/notifications", } ), @@ -369,7 +380,7 @@ def test_payment_backend_lyra_create_payment_accepted(self): json=json_response, ) - response = backend.create_payment(order, billing_address) + response = backend.create_payment(order, first_installment, billing_address) self.assertEqual( response, @@ -383,6 +394,7 @@ def test_payment_backend_lyra_create_payment_accepted(self): }, ) + @override_settings(JOANIE_PAYMENT_SCHEDULE_LIMITS={0: (30, 70)}) @responses.activate(assert_all_requests_are_fired=True) def test_payment_backend_lyra_create_payment_accepted_with_installment(self): """ @@ -390,38 +402,17 @@ def test_payment_backend_lyra_create_payment_accepted_with_installment(self): """ backend = LyraBackend(self.configuration) owner = UserFactory(email="john.doe@acme.org") - product = ProductFactory(price=D("123.45")) - order = OrderFactory( + order = OrderGeneratorFactory( + state=ORDER_STATE_PENDING, owner=owner, - product=product, - payment_schedule=[ - { - "id": "d9356dd7-19a6-4695-b18e-ad93af41424a", - "amount": "200.00", - "due_date": "2024-01-17", - "state": PAYMENT_STATE_PENDING, - }, - { - "id": "1932fbc5-d971-48aa-8fee-6d637c3154a5", - "amount": "300.00", - "due_date": "2024-02-17", - "state": PAYMENT_STATE_PENDING, - }, - { - "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, - }, - ], + product__price=D("123.45"), ) - billing_address = UserAddressFactory(owner=owner) + # Force the first installment id to match the stored request + first_installment = order.payment_schedule[0] + first_installment["id"] = "d9356dd7-19a6-4695-b18e-ad93af41424a" + order.save() + owner = order.owner + billing_address = order.main_invoice.recipient_address with self.open("lyra/responses/create_payment.json") as file: json_response = json.loads(file.read()) @@ -441,7 +432,7 @@ def test_payment_backend_lyra_create_payment_accepted_with_installment(self): ), responses.matchers.json_params_matcher( { - "amount": 20000, + "amount": 3704, "currency": "EUR", "customer": { "email": "john.doe@acme.org", @@ -460,7 +451,7 @@ def test_payment_backend_lyra_create_payment_accepted_with_installment(self): }, }, "orderId": str(order.id), - "formAction": "ASK_REGISTER_PAY", + "formAction": "REGISTER_PAY", "ipnTargetUrl": "https://example.com/api/v1.0/payments/notifications", "metadata": { "installment_id": "d9356dd7-19a6-4695-b18e-ad93af41424a" @@ -473,7 +464,7 @@ def test_payment_backend_lyra_create_payment_accepted_with_installment(self): ) response = backend.create_payment( - order, billing_address, installment=order.payment_schedule[0] + order, order.payment_schedule[0], billing_address ) self.assertEqual( @@ -610,16 +601,20 @@ def test_payment_backend_lyra_tokenize_card_passing_user_in_parameter_only(self) }, ) + @override_settings(JOANIE_PAYMENT_SCHEDULE_LIMITS={0: (30, 70)}) @responses.activate(assert_all_requests_are_fired=True) def test_payment_backend_lyra_create_one_click_payment(self): """ When backend creates a one click payment, it should return payment information. """ backend = LyraBackend(self.configuration) - owner = UserFactory(email="john.doe@acme.org") - product = ProductFactory(price=D("123.45")) - order = OrderFactory(owner=owner, product=product) - billing_address = UserAddressFactory(owner=owner) + order = OrderGeneratorFactory( + state=ORDER_STATE_PENDING, + product__price=D("123.45"), + ) + owner = order.owner + billing_address = order.main_invoice.recipient_address + first_installment = order.payment_schedule[0] credit_card = CreditCardFactory( owner=owner, token="854d630f17f54ee7bce03fb4fcf764e9" ) @@ -642,10 +637,10 @@ def test_payment_backend_lyra_create_one_click_payment(self): ), responses.matchers.json_params_matcher( { - "amount": 12345, + "amount": 3704, "currency": "EUR", "customer": { - "email": "john.doe@acme.org", + "email": order.owner.email, "reference": str(owner.id), "billingDetails": { "firstName": billing_address.first_name, @@ -661,6 +656,9 @@ def test_payment_backend_lyra_create_one_click_payment(self): }, }, "orderId": str(order.id), + "metadata": { + "installment_id": str(first_installment.get("id")) + }, "formAction": "PAYMENT", "paymentMethodToken": credit_card.token, "ipnTargetUrl": "https://example.com/api/v1.0/payments/notifications", @@ -672,7 +670,7 @@ def test_payment_backend_lyra_create_one_click_payment(self): ) response = backend.create_one_click_payment( - order, billing_address, credit_card.token + order, first_installment, credit_card.token, billing_address ) self.assertEqual( @@ -687,48 +685,29 @@ def test_payment_backend_lyra_create_one_click_payment(self): }, ) + @override_settings(JOANIE_PAYMENT_SCHEDULE_LIMITS={0: (30, 70)}) @responses.activate(assert_all_requests_are_fired=True) def test_payment_backend_lyra_create_one_click_payment_with_installment(self): """ When backend creates a one click payment, it should return payment information. """ backend = LyraBackend(self.configuration) - owner = UserFactory(email="john.doe@acme.org") - product = ProductFactory(price=D("123.45")) - order = OrderFactory( + owner = UserFactory(email="john.doe@acme.org", language="en-us") + order = OrderGeneratorFactory( + state=ORDER_STATE_PENDING, + id="514070fe-c12c-48b8-97cf-5262708673a3", owner=owner, - product=product, - payment_schedule=[ - { - "id": "d9356dd7-19a6-4695-b18e-ad93af41424a", - "amount": "200.00", - "due_date": "2024-01-17", - "state": PAYMENT_STATE_PENDING, - }, - { - "id": "1932fbc5-d971-48aa-8fee-6d637c3154a5", - "amount": "300.00", - "due_date": "2024-02-17", - "state": PAYMENT_STATE_PENDING, - }, - { - "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, - }, - ], - ) - billing_address = UserAddressFactory(owner=owner) - credit_card = CreditCardFactory( - owner=owner, token="854d630f17f54ee7bce03fb4fcf764e9" - ) + product__price=D("123.45"), + credit_card__is_main=True, + credit_card__initial_issuer_transaction_identifier="1", + ) + # Force the first installment id to match the stored request + first_installment = order.payment_schedule[0] + first_installment["id"] = "d9356dd7-19a6-4695-b18e-ad93af41424a" + order.save() + owner = order.owner + billing_address = order.main_invoice.recipient_address + credit_card = order.credit_card with self.open("lyra/responses/create_one_click_payment.json") as file: json_response = json.loads(file.read()) @@ -748,7 +727,7 @@ def test_payment_backend_lyra_create_one_click_payment_with_installment(self): ), responses.matchers.json_params_matcher( { - "amount": 20000, + "amount": 3704, "currency": "EUR", "customer": { "email": "john.doe@acme.org", @@ -782,9 +761,9 @@ def test_payment_backend_lyra_create_one_click_payment_with_installment(self): response = backend.create_one_click_payment( order, - billing_address, + order.payment_schedule[0], credit_card.token, - installment=order.payment_schedule[0], + billing_address, ) self.assertEqual( @@ -799,6 +778,7 @@ def test_payment_backend_lyra_create_one_click_payment_with_installment(self): }, ) + @override_settings(JOANIE_PAYMENT_SCHEDULE_LIMITS={0: (30, 70)}) @responses.activate(assert_all_requests_are_fired=True) def test_payment_backend_lyra_create_zero_click_payment(self): """ @@ -811,41 +791,29 @@ def test_payment_backend_lyra_create_zero_click_payment(self): last_name="Doe", language="en-us", ) - product = ProductFactory(price=D("123.45")) - first_installment_amount = product.price / 3 - second_installment_amount = product.price - first_installment_amount - - order = OrderFactory( - owner=owner, - product=product, + product = ProductFactory(price=D("123.45"), title="Product 1") + product.translations.create(language_code="fr-fr", title="Produit 1") + order = OrderGeneratorFactory( state=ORDER_STATE_PENDING, - payment_schedule=[ - { - "id": "d9356dd7-19a6-4695-b18e-ad93af41424a", - "amount": f"{first_installment_amount}", - "due_date": "2024-01-17", - "state": PAYMENT_STATE_PENDING, - }, - { - "id": "1932fbc5-d971-48aa-8fee-6d637c3154a5", - "amount": f"{second_installment_amount}", - "due_date": "2024-02-17", - "state": PAYMENT_STATE_PENDING, - }, - ], - ) - credit_card = CreditCardFactory( owner=owner, - token="854d630f17f54ee7bce03fb4fcf764e9", - initial_issuer_transaction_identifier="4575676657929351", + product=product, ) + # Force the installments id to match the stored request + first_installment = order.payment_schedule[0] + first_installment["id"] = "d9356dd7-19a6-4695-b18e-ad93af41424a" + second_installment = order.payment_schedule[1] + second_installment["id"] = "1932fbc5-d971-48aa-8fee-6d637c3154a5" + order.save() + first_installment_amount = order.payment_schedule[0]["amount"] + second_installment_amount = order.payment_schedule[1]["amount"] + credit_card = order.credit_card with self.open("lyra/responses/create_zero_click_payment.json") as file: json_response = json.loads(file.read()) json_response["answer"]["transactions"][0]["uuid"] = "first_transaction_id" json_response["answer"]["orderDetails"]["orderTotalAmount"] = int( - first_installment_amount * 100 + first_installment_amount.sub_units ) responses.add( @@ -892,7 +860,7 @@ def test_payment_backend_lyra_create_zero_click_payment(self): ) response = backend.create_zero_click_payment( - order, credit_card.token, installment=order.payment_schedule[0] + order, order.payment_schedule[0], credit_card.token ) self.assertTrue(response) @@ -906,7 +874,7 @@ def test_payment_backend_lyra_create_zero_click_payment(self): self.assertTrue( Transaction.objects.filter( invoice__parent__order=order, - total=first_installment_amount, + total=first_installment_amount.as_decimal(), reference="first_transaction_id", ).exists() ) @@ -918,15 +886,13 @@ def test_payment_backend_lyra_create_zero_click_payment(self): self.assertEqual(order.payment_schedule[0]["state"], PAYMENT_STATE_PAID) # Mail is sent - self._check_order_validated_email_sent( - owner.email, owner.get_full_name(), order - ) + self._check_installment_paid_email_sent(owner.email, order) mail.outbox.clear() json_response["answer"]["transactions"][0]["uuid"] = "second_transaction_id" json_response["answer"]["orderDetails"]["orderTotalAmount"] = int( - second_installment_amount * 100 + second_installment_amount.sub_units ) responses.add( @@ -973,7 +939,7 @@ def test_payment_backend_lyra_create_zero_click_payment(self): ) backend.create_zero_click_payment( - order, credit_card.token, installment=order.payment_schedule[1] + order, order.payment_schedule[1], credit_card.token ) # Children invoice is created @@ -984,7 +950,7 @@ def test_payment_backend_lyra_create_zero_click_payment(self): self.assertTrue( Transaction.objects.filter( invoice__parent__order=order, - total=second_installment_amount, + total=second_installment_amount.as_decimal(), reference="second_transaction_id", ).exists() ) @@ -994,11 +960,8 @@ def test_payment_backend_lyra_create_zero_click_payment(self): self.assertEqual(order.state, ORDER_STATE_COMPLETED) # Second installment is paid self.assertEqual(order.payment_schedule[1]["state"], PAYMENT_STATE_PAID) - - # Mail is sent - self._check_order_validated_email_sent( - owner.email, owner.get_full_name(), order - ) + email_content = " ".join(mail.outbox[0].body.split()) + self.assertIn("Product 1", email_content) def test_payment_backend_lyra_handle_notification_unknown_resource(self): """ @@ -1053,14 +1016,19 @@ def test_payment_backend_lyra_handle_notification_payment_failure( ): """ When backend receives a payment notification which failed, the generic - method `_do_on_failure` should be called. + method `_do_on_payment_failure` should be called. """ backend = LyraBackend(self.configuration) - owner = UserFactory(email="john.doe@acme.org") - product = ProductFactory(price=D("123.45")) - order = OrderFactory( - id="758c2570-a7af-4335-b091-340d0cc6e694", owner=owner, product=product + order = OrderGeneratorFactory( + state=ORDER_STATE_PENDING, + id="758c2570-a7af-4335-b091-340d0cc6e694", + owner__email="john.doe@acme.org", + product__price=D("123.45"), ) + # Force the first installment id to match the stored request + first_installment = order.payment_schedule[0] + first_installment["id"] = "d9356dd7-19a6-4695-b18e-ad93af41424a" + order.save() with self.open("lyra/requests/payment_refused.json") as file: json_request = json.loads(file.read()) @@ -1071,7 +1039,9 @@ def test_payment_backend_lyra_handle_notification_payment_failure( backend.handle_notification(request) - mock_do_on_payment_failure.assert_called_once_with(order, installment_id=None) + mock_do_on_payment_failure.assert_called_once_with( + order, first_installment["id"] + ) @patch.object(BasePaymentBackend, "_do_on_payment_success") def test_payment_backend_lyra_handle_notification_payment( @@ -1082,11 +1052,18 @@ def test_payment_backend_lyra_handle_notification_payment( method `_do_on_payment_success` should be called. """ backend = LyraBackend(self.configuration) - owner = UserFactory(email="john.doe@acme.org") - product = ProductFactory(price=D("123.45")) - order = OrderFactory( - id="514070fe-c12c-48b8-97cf-5262708673a3", owner=owner, product=product + order = OrderGeneratorFactory( + state=ORDER_STATE_PENDING, + id="514070fe-c12c-48b8-97cf-5262708673a3", + owner__email="john.doe@acme.org", + product__price=D("123.45"), + credit_card__is_main=True, + credit_card__initial_issuer_transaction_identifier="1", ) + # Force the first installment id to match the stored request + first_installment = order.payment_schedule[0] + first_installment["id"] = "d9356dd7-19a6-4695-b18e-ad93af41424a" + order.save() with self.open("lyra/requests/payment_accepted_no_store_card.json") as file: json_request = json.loads(file.read()) @@ -1117,7 +1094,7 @@ def test_payment_backend_lyra_handle_notification_payment( "last_name": billing_details["lastName"], "postcode": billing_details["zipCode"], }, - "installment_id": None, + "installment_id": first_installment["id"], }, ) @@ -1129,10 +1106,18 @@ def test_payment_backend_lyra_handle_notification_payment_mail(self): """ backend = LyraBackend(self.configuration) owner = UserFactory(email="john.doe@acme.org", language="en-us") - product = ProductFactory(price=D("123.45")) - order = OrderFactory( - id="514070fe-c12c-48b8-97cf-5262708673a3", owner=owner, product=product + order = OrderGeneratorFactory( + state=ORDER_STATE_PENDING, + id="514070fe-c12c-48b8-97cf-5262708673a3", + owner=owner, + product__price=D("123.45"), + credit_card__is_main=True, + credit_card__initial_issuer_transaction_identifier="1", ) + # Force the first installment id to match the stored request + first_installment = order.payment_schedule[0] + first_installment["id"] = "d9356dd7-19a6-4695-b18e-ad93af41424a" + order.save() with self.open("lyra/requests/payment_accepted_no_store_card.json") as file: json_request = json.loads(file.read()) @@ -1144,9 +1129,7 @@ def test_payment_backend_lyra_handle_notification_payment_mail(self): backend.handle_notification(request) # Email has been sent - self._check_order_validated_email_sent( - order.owner.email, order.owner.get_full_name(), order - ) + self._check_installment_paid_email_sent(order.owner.email, order) @patch.object(BasePaymentBackend, "_do_on_payment_success") def test_payment_backend_lyra_handle_notification_payment_register_card( @@ -1219,7 +1202,10 @@ def test_payment_backend_lyra_handle_notification_one_click_payment( owner = UserFactory(email="john.doe@acme.org") product = ProductFactory(price=D("123.45")) order = OrderFactory( - id="93e64f3a-6b60-475a-91e3-f4b8a364a844", owner=owner, product=product + id="93e64f3a-6b60-475a-91e3-f4b8a364a844", + owner=owner, + product=product, + credit_card=None, ) with self.open("lyra/requests/one_click_payment_accepted.json") as file: @@ -1315,6 +1301,90 @@ def test_payment_backend_lyra_handle_notification_tokenize_card( initial_issuer_transaction_identifier, ) + def test_payment_backend_lyra_handle_notification_tokenize_card_for_user(self): + """ + When backend receives a credit card tokenization notification for a user, + it should not try to find a related order and create directly a card for the giver user. + """ + backend = LyraBackend(self.configuration) + user = UserFactory( + email="john.doe@acme.org", id="0a920c52-7ecc-47b3-83f5-127b846ac79c" + ) + + with self.open("lyra/requests/tokenize_card_for_user.json") as file: + json_request = json.loads(file.read()) + + with self.open("lyra/requests/tokenize_card_for_user_answer.json") as file: + json_answer = json.loads(file.read()) + + self.assertFalse(CreditCard.objects.filter(owner=user).exists()) + + request = APIRequestFactory().post( + reverse("payment_webhook"), data=json_request, format="multipart" + ) + + backend.handle_notification(request) + + card_id = json_answer["transactions"][0]["paymentMethodToken"] + initial_issuer_transaction_identifier = json_answer["transactions"][0][ + "transactionDetails" + ]["cardDetails"]["initialIssuerTransactionIdentifier"] + card = CreditCard.objects.get(token=card_id) + self.assertEqual(card.owner, user) + self.assertEqual(card.payment_provider, backend.name) + self.assertEqual( + card.initial_issuer_transaction_identifier, + initial_issuer_transaction_identifier, + ) + + def test_payment_backend_lyra_handle_notification_tokenize_card_for_user_not_found( + self, + ): + """ + When backend receives a credit card tokenization notification for a user, + and this user does not exists, it should raises a TokenizationCardFailed + """ + backend = LyraBackend(self.configuration) + user = UserFactory(email="john.doe@acme.org") + + with self.open("lyra/requests/tokenize_card_for_user.json") as file: + json_request = json.loads(file.read()) + + self.assertFalse(CreditCard.objects.filter(owner=user).exists()) + + request = APIRequestFactory().post( + reverse("payment_webhook"), data=json_request, format="multipart" + ) + with self.assertRaises(TokenizationCardFailed): + backend.handle_notification(request) + + self.assertFalse(CreditCard.objects.filter(owner=user).exists()) + + def test_payment_backend_lyra_handle_notification_tokenize_card_for_user_failure( + self, + ): + """ + When backend receives a credit card tokenization notification for a user, + and the tokenization has failed, it should not create a new card + """ + backend = LyraBackend(self.configuration) + user = UserFactory( + email="john.doe@acme.org", id="0a920c52-7ecc-47b3-83f5-127b846ac79c" + ) + + with self.open("lyra/requests/tokenize_card_for_user_unpaid.json") as file: + json_request = json.loads(file.read()) + + self.assertFalse(CreditCard.objects.filter(owner=user).exists()) + + request = APIRequestFactory().post( + reverse("payment_webhook"), data=json_request, format="multipart" + ) + + backend.handle_notification(request) + + self.assertFalse(CreditCard.objects.filter(owner=user).exists()) + @responses.activate(assert_all_requests_are_fired=True) def test_payment_backend_lyra_delete_credit_card(self): """ @@ -1362,39 +1432,8 @@ def test_payment_backend_lyra_create_zero_click_payment_request_exception_error( of the error. """ backend = LyraBackend(self.configuration) - owner = UserFactory( - email="john.doe@acme.org", - first_name="John", - last_name="Doe", - language="en-us", - ) - product = ProductFactory(price=D("134.45")) - first_installment_amount = product.price / 3 - second_installment_amount = product.price - first_installment_amount - order = OrderFactory( - owner=owner, - product=product, - state=ORDER_STATE_PENDING, - payment_schedule=[ - { - "id": "d9356dd7-19a6-4695-b18e-ad93af41424a", - "amount": f"{first_installment_amount}", - "due_date": "2024-01-17", - "state": PAYMENT_STATE_PENDING, - }, - { - "id": "1932fbc5-d971-48aa-8fee-6d637c3154a5", - "amount": f"{second_installment_amount}", - "due_date": "2024-02-17", - "state": PAYMENT_STATE_PENDING, - }, - ], - ) - credit_card = CreditCardFactory( - owner=owner, - token="854d630f17f54ee7bce03fb4fcf764e9", - initial_issuer_transaction_identifier="4575676657929351", - ) + order = OrderGeneratorFactory(state=ORDER_STATE_PENDING) + credit_card = order.credit_card responses.add( responses.POST, @@ -1407,7 +1446,7 @@ def test_payment_backend_lyra_create_zero_click_payment_request_exception_error( self.assertLogs() as logger, ): backend.create_zero_click_payment( - order, credit_card.token, installment=order.payment_schedule[1] + order, order.payment_schedule[1], credit_card.token ) self.assertEqual( @@ -1445,34 +1484,8 @@ def test_backend_lyra_create_zero_click_payment_server_error(self): PaymentProviderAPIException is raised with information about the source of the error. """ backend = LyraBackend(self.configuration) - owner = UserFactory(email="john.doe@acme.org") - product = ProductFactory(price=D("134.45")) - first_installment_amount = product.price / 3 - second_installment_amount = product.price - first_installment_amount - order = OrderFactory( - owner=owner, - product=product, - state=ORDER_STATE_PENDING, - payment_schedule=[ - { - "id": "d9356dd7-19a6-4695-b18e-ad93af41424a", - "amount": f"{first_installment_amount}", - "due_date": "2024-01-17", - "state": PAYMENT_STATE_PENDING, - }, - { - "id": "1932fbc5-d971-48aa-8fee-6d637c3154a5", - "amount": f"{second_installment_amount}", - "due_date": "2024-02-17", - "state": PAYMENT_STATE_PENDING, - }, - ], - ) - credit_card = CreditCardFactory( - owner=owner, - token="854d630f17f54ee7bce03fb4fcf764e9", - initial_issuer_transaction_identifier="4575676657929351", - ) + order = OrderGeneratorFactory(state=ORDER_STATE_PENDING) + credit_card = order.credit_card responses.add( responses.POST, @@ -1486,7 +1499,7 @@ def test_backend_lyra_create_zero_click_payment_server_error(self): self.assertLogs() as logger, ): backend.create_zero_click_payment( - order, credit_card.token, installment=order.payment_schedule[0] + order, order.payment_schedule[0], credit_card.token ) self.assertEqual( @@ -1521,3 +1534,184 @@ def test_backend_lyra_create_zero_click_payment_server_error(self): ), ] self.assertLogsEquals(logger.records, expected_logs) + + @patch.object(BasePaymentBackend, "_send_mail_refused_debit") + def test_payment_backend_lyra_handle_notification_payment_failure_sends_email( + self, mock_send_mail_refused_debit + ): + """ + When backend receives a payment notification which failed, the generic + method `_do_on_payment_failure` should be called and it must also call + the method responsible to send the email to the user. + """ + backend = LyraBackend(self.configuration) + user = UserFactory( + first_name="John", + last_name="Doe", + language="en-us", + email="john.doe@acme.org", + ) + order = OrderGeneratorFactory( + state=ORDER_STATE_PENDING, + id="758c2570-a7af-4335-b091-340d0cc6e694", + owner=user, + product__price=D("123.45"), + ) + # Force the first installment id to match the stored request + first_installment = order.payment_schedule[0] + first_installment["id"] = "d9356dd7-19a6-4695-b18e-ad93af41424a" + order.save() + + with self.open("lyra/requests/payment_refused.json") as file: + json_request = json.loads(file.read()) + + request = APIRequestFactory().post( + reverse("payment_webhook"), data=json_request, format="multipart" + ) + + backend.handle_notification(request) + + mock_send_mail_refused_debit.assert_called_once_with( + order, first_installment["id"] + ) + + def test_payment_backend_lyra_handle_notification_payment_failure_send_mail_in_user_language( + self, + ): + """ + When backend receives a payment notification which failed, the generic + method `_do_on_payment_failure` should be called and the email must be sent + in the preferred language of the user. + """ + backend = LyraBackend(self.configuration) + user = UserFactory( + first_name="John", + last_name="Doe", + language="en-us", + email="john.doe@acme.org", + ) + product = ProductFactory(price=D("1000.00"), title="Product 1") + order = OrderGeneratorFactory( + state=ORDER_STATE_PENDING, + id="758c2570-a7af-4335-b091-340d0cc6e694", + owner=user, + product=product, + ) + # Force the first installment id to match the stored request + first_installment = order.payment_schedule[0] + first_installment["id"] = "d9356dd7-19a6-4695-b18e-ad93af41424a" + order.save() + + with self.open("lyra/requests/payment_refused.json") as file: + json_request = json.loads(file.read()) + + request = APIRequestFactory().post( + reverse("payment_webhook"), data=json_request, format="multipart" + ) + + backend.handle_notification(request) + + email_content = " ".join(mail.outbox[0].body.split()) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to[0], "john.doe@acme.org") + self.assertIn( + "An installment debit has failed", + mail.outbox[0].subject, + ) + self.assertIn("Product 1", email_content) + self.assertIn("installment debit has failed", email_content) + + def test_payment_backend_lyra_payment_failure_send_mail_in_user_language_that_is_french( + self, + ): + """ + When backend receives a payment notification which failed, the generic + method `_do_on_payment_failure` should be called and the email must be sent + in the preferred language of the user. In our case, it will be the French language. + """ + backend = LyraBackend(self.configuration) + user = UserFactory( + first_name="John", + last_name="Doe", + language="fr-fr", + email="john.doe@acme.org", + ) + product = ProductFactory(price=D("1000.00"), title="Product 1") + product.translations.create(language_code="fr-fr", title="Produit 1") + order = OrderGeneratorFactory( + state=ORDER_STATE_PENDING, + id="758c2570-a7af-4335-b091-340d0cc6e694", + owner=user, + product=product, + ) + # Force the first installment id to match the stored request + first_installment = order.payment_schedule[0] + first_installment["id"] = "d9356dd7-19a6-4695-b18e-ad93af41424a" + order.save() + + with self.open("lyra/requests/payment_refused.json") as file: + json_request = json.loads(file.read()) + + request = APIRequestFactory().post( + reverse("payment_webhook"), data=json_request, format="multipart" + ) + + backend.handle_notification(request) + + email_content = " ".join(mail.outbox[0].body.split()) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to[0], "john.doe@acme.org") + self.assertIn("Produit 1", email_content) + + @override_settings( + LANGUAGES=( + ("en-us", ("English")), + ("fr-fr", ("French")), + ("de-de", ("German")), + ) + ) + def test_payment_backend_lyra_payment_failure_send_mail_use_fallback_language_translation( + self, + ): + """ + When backend receives a payment notification which failed, the generic + method `_do_on_payment_failure` should be called and the email must be sent + in the fallback language if the translation does not exist. + """ + backend = LyraBackend(self.configuration) + user = UserFactory( + first_name="John", + last_name="Doe", + language="de-de", + email="john.doe@acme.org", + ) + product = ProductFactory(price=D("1000.00"), title="Product 1") + product.translations.create(language_code="fr-fr", title="Produit 1") + order = OrderGeneratorFactory( + state=ORDER_STATE_PENDING, + id="758c2570-a7af-4335-b091-340d0cc6e694", + owner=user, + product=product, + ) + # Force the first installment id to match the stored request + first_installment = order.payment_schedule[0] + first_installment["id"] = "d9356dd7-19a6-4695-b18e-ad93af41424a" + order.save() + + with self.open("lyra/requests/payment_refused.json") as file: + json_request = json.loads(file.read()) + + request = APIRequestFactory().post( + reverse("payment_webhook"), data=json_request, format="multipart" + ) + + backend.handle_notification(request) + + email_content = " ".join(mail.outbox[0].body.split()) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to[0], "john.doe@acme.org") + self.assertIn( + "An installment debit has failed", + mail.outbox[0].subject, + ) + self.assertIn("Product 1", email_content) diff --git a/src/backend/joanie/tests/payment/test_backend_payplug.py b/src/backend/joanie/tests/payment/test_backend_payplug.py index 7f89a6d8e..a15d4fe7a 100644 --- a/src/backend/joanie/tests/payment/test_backend_payplug.py +++ b/src/backend/joanie/tests/payment/test_backend_payplug.py @@ -4,6 +4,7 @@ from decimal import Decimal as D from unittest import mock +from django.core import mail from django.test import override_settings from django.urls import reverse @@ -12,7 +13,13 @@ from rest_framework.test import APIRequestFactory from joanie.core import enums -from joanie.core.factories import OrderFactory, ProductFactory, UserFactory +from joanie.core.enums import ORDER_STATE_PENDING +from joanie.core.factories import ( + OrderFactory, + OrderGeneratorFactory, + ProductFactory, + UserFactory, +) from joanie.payment.backends.base import BasePaymentBackend from joanie.payment.backends.payplug import PayplugBackend from joanie.payment.backends.payplug import factories as PayplugFactories @@ -25,14 +32,13 @@ ) from joanie.payment.factories import ( BillingAddressDictFactory, - CreditCardFactory, TransactionFactory, ) from joanie.payment.models import CreditCard from joanie.tests.payment.base_payment import BasePaymentTestCase -# pylint: disable=too-many-public-methods +# pylint: disable=too-many-public-methods, too-many-lines class PayplugBackendTestCase(BasePaymentTestCase): """Test case of the Payplug backend""" @@ -59,26 +65,29 @@ def test_payment_backend_payplug_configuration(self): self.assertEqual(str(context.exception), "'secret_key'") + @override_settings(JOANIE_PAYMENT_SCHEDULE_LIMITS={0: (30, 70)}) def test_payment_backend_payplug_get_payment_data(self): """ Payplug backend has `_get_payment_data` method which should return the common payload to create a payment or a one click payment. """ backend = PayplugBackend(self.configuration) - owner = UserFactory(email="john.doe@acme.org") - product = ProductFactory(price=D("123.45")) - order = OrderFactory(owner=owner, product=product) - billing_address = BillingAddressDictFactory() + order = OrderGeneratorFactory( + state=enums.ORDER_STATE_PENDING, + product__price=D("123.45"), + ) + billing_address = order.main_invoice.recipient_address.to_dict() + first_installment = order.payment_schedule[0] # pylint: disable=protected-access - payload = backend._get_payment_data(order, billing_address) + payload = backend._get_payment_data(order, first_installment, billing_address) self.assertEqual( payload, { - "amount": 12345, + "amount": 3704, "currency": "EUR", "billing": { - "email": "john.doe@acme.org", + "email": order.owner.email, "first_name": billing_address["first_name"], "last_name": billing_address["last_name"], "address1": billing_address["address"], @@ -88,7 +97,10 @@ def test_payment_backend_payplug_get_payment_data(self): }, "shipping": {"delivery_type": "DIGITAL_GOODS"}, "notification_url": "https://example.com/api/v1.0/payments/notifications", - "metadata": {"order_id": str(order.id)}, + "metadata": { + "order_id": str(order.id), + "installment_id": str(first_installment.get("id")), + }, }, ) @@ -100,17 +112,21 @@ def test_payment_backend_payplug_create_payment_failed(self, mock_payplug_create """ mock_payplug_create.side_effect = BadRequest("Endpoint unreachable") backend = PayplugBackend(self.configuration) - order = OrderFactory(product=ProductFactory()) - billing_address = BillingAddressDictFactory() + order = OrderGeneratorFactory(state=enums.ORDER_STATE_PENDING) with self.assertRaises(CreatePaymentFailed) as context: - backend.create_payment(order, billing_address) + backend.create_payment( + order, + order.payment_schedule[0], + order.main_invoice.recipient_address.to_dict(), + ) self.assertEqual( str(context.exception), "Bad request. The server gave the following response: `Endpoint unreachable`.", ) + @override_settings(JOANIE_PAYMENT_SCHEDULE_LIMITS={0: (30, 70)}) @mock.patch.object(payplug.Payment, "create") def test_payment_backend_payplug_create_payment(self, mock_payplug_create): """ @@ -118,20 +134,23 @@ def test_payment_backend_payplug_create_payment(self, mock_payplug_create): """ mock_payplug_create.return_value = PayplugFactories.PayplugPaymentFactory() backend = PayplugBackend(self.configuration) - owner = UserFactory(email="john.doe@acme.org") product = ProductFactory(price=D("123.45")) - order = OrderFactory(owner=owner, product=product) - billing_address = BillingAddressDictFactory() + order = OrderGeneratorFactory( + state=enums.ORDER_STATE_PENDING, + product=product, + ) + billing_address = order.main_invoice.recipient_address.to_dict() + installment = order.payment_schedule[0] - payload = backend.create_payment(order, billing_address) + payload = backend.create_payment(order, installment, billing_address) mock_payplug_create.assert_called_once_with( **{ - "amount": 12345, + "amount": 3704, "allow_save_card": True, "currency": "EUR", "billing": { - "email": "john.doe@acme.org", + "email": order.owner.email, "first_name": billing_address["first_name"], "last_name": billing_address["last_name"], "address1": billing_address["address"], @@ -141,7 +160,10 @@ def test_payment_backend_payplug_create_payment(self, mock_payplug_create): }, "shipping": {"delivery_type": "DIGITAL_GOODS"}, "notification_url": "https://example.com/api/v1.0/payments/notifications", - "metadata": {"order_id": str(order.id)}, + "metadata": { + "order_id": str(order.id), + "installment_id": str(installment.get("id")), + }, } ) self.assertEqual(len(payload), 3) @@ -149,6 +171,7 @@ def test_payment_backend_payplug_create_payment(self, mock_payplug_create): self.assertIsNotNone(re.fullmatch(r"pay_\d{5}", payload["payment_id"])) self.assertIsNotNone(payload["url"]) + @override_settings(JOANIE_PAYMENT_SCHEDULE_LIMITS={0: (30, 70)}) @mock.patch.object(payplug.Payment, "create") def test_payment_backend_payplug_create_payment_with_installment( self, mock_payplug_create @@ -158,51 +181,24 @@ def test_payment_backend_payplug_create_payment_with_installment( """ mock_payplug_create.return_value = PayplugFactories.PayplugPaymentFactory() backend = PayplugBackend(self.configuration) - owner = UserFactory(email="john.doe@acme.org") - product = ProductFactory(price=D("123.45")) - order = OrderFactory( - owner=owner, - product=product, - payment_schedule=[ - { - "id": "d9356dd7-19a6-4695-b18e-ad93af41424a", - "amount": "200.00", - "due_date": "2024-01-17", - "state": enums.PAYMENT_STATE_PENDING, - }, - { - "id": "1932fbc5-d971-48aa-8fee-6d637c3154a5", - "amount": "300.00", - "due_date": "2024-02-17", - "state": enums.PAYMENT_STATE_PENDING, - }, - { - "id": "168d7e8c-a1a9-4d70-9667-853bf79e502c", - "amount": "300.00", - "due_date": "2024-03-17", - "state": enums.PAYMENT_STATE_PENDING, - }, - { - "id": "9fcff723-7be4-4b77-87c6-2865e000f879", - "amount": "199.99", - "due_date": "2024-04-17", - "state": enums.PAYMENT_STATE_PENDING, - }, - ], + order = OrderGeneratorFactory( + state=ORDER_STATE_PENDING, + product__price=D("123.45"), ) - billing_address = BillingAddressDictFactory() + billing_address = order.main_invoice.recipient_address.to_dict() + first_installment = order.payment_schedule[0] payload = backend.create_payment( - order, billing_address, installment=order.payment_schedule[0] + order, order.payment_schedule[0], billing_address ) mock_payplug_create.assert_called_once_with( **{ - "amount": 20000, + "amount": 3704, "allow_save_card": True, "currency": "EUR", "billing": { - "email": "john.doe@acme.org", + "email": order.owner.email, "first_name": billing_address["first_name"], "last_name": billing_address["last_name"], "address1": billing_address["address"], @@ -214,7 +210,7 @@ def test_payment_backend_payplug_create_payment_with_installment( "notification_url": "https://example.com/api/v1.0/payments/notifications", "metadata": { "order_id": str(order.id), - "installment_id": "d9356dd7-19a6-4695-b18e-ad93af41424a", + "installment_id": str(first_installment["id"]), }, } ) @@ -223,6 +219,7 @@ def test_payment_backend_payplug_create_payment_with_installment( self.assertIsNotNone(re.fullmatch(r"pay_\d{5}", payload["payment_id"])) self.assertIsNotNone(payload["url"]) + @override_settings(JOANIE_PAYMENT_SCHEDULE_LIMITS={0: (30, 70)}) @mock.patch.object(PayplugBackend, "create_payment") @mock.patch.object(payplug.Payment, "create") def test_payment_backend_payplug_create_one_click_payment_request_failed( @@ -233,11 +230,12 @@ def test_payment_backend_payplug_create_one_click_payment_request_failed( failed, it should fallback to create_payment method. """ backend = PayplugBackend(self.configuration) - owner = UserFactory(email="john.doe@acme.org") - product = ProductFactory(price=D("123.45")) - order = OrderFactory(owner=owner, product=product) - billing_address = BillingAddressDictFactory() - credit_card = CreditCardFactory() + order = OrderGeneratorFactory( + state=ORDER_STATE_PENDING, + product__price=D("123.45"), + ) + billing_address = order.main_invoice.recipient_address.to_dict() + first_installment = order.payment_schedule[0] mock_payplug_create.side_effect = BadRequest() mock_backend_create_payment.return_value = { @@ -248,19 +246,19 @@ def test_payment_backend_payplug_create_one_click_payment_request_failed( } payload = backend.create_one_click_payment( - order, billing_address, credit_card.token + order, first_installment, order.credit_card.token, billing_address ) # - One click payment create has been called mock_payplug_create.assert_called_once_with( **{ - "amount": 12345, + "amount": 3704, "allow_save_card": False, "initiator": "PAYER", - "payment_method": credit_card.token, + "payment_method": order.credit_card.token, "currency": "EUR", "billing": { - "email": "john.doe@acme.org", + "email": order.owner.email, "first_name": billing_address["first_name"], "last_name": billing_address["last_name"], "address1": billing_address["address"], @@ -270,12 +268,17 @@ def test_payment_backend_payplug_create_one_click_payment_request_failed( }, "shipping": {"delivery_type": "DIGITAL_GOODS"}, "notification_url": "https://example.com/api/v1.0/payments/notifications", - "metadata": {"order_id": str(order.id)}, + "metadata": { + "order_id": str(order.id), + "installment_id": str(first_installment.get("id")), + }, } ) # - As fallback `create_payment` has been called - mock_backend_create_payment.assert_called_once_with(order, billing_address) + mock_backend_create_payment.assert_called_once_with( + order, first_installment, billing_address + ) self.assertEqual( payload, { @@ -286,6 +289,7 @@ def test_payment_backend_payplug_create_one_click_payment_request_failed( }, ) + @override_settings(JOANIE_PAYMENT_SCHEDULE_LIMITS={0: (30, 70)}) @mock.patch.object(payplug.Payment, "create") def test_payment_backend_payplug_create_one_click_payment_not_authorized( self, mock_payplug_create @@ -298,26 +302,28 @@ def test_payment_backend_payplug_create_one_click_payment_not_authorized( is_paid=False ) backend = PayplugBackend(self.configuration) - owner = UserFactory(email="john.doe@acme.org") - product = ProductFactory(price=D("123.45")) - order = OrderFactory(owner=owner, product=product) - billing_address = BillingAddressDictFactory() - credit_card = CreditCardFactory() + order = OrderGeneratorFactory( + state=ORDER_STATE_PENDING, + product__price=D("123.45"), + ) + billing_address = order.main_invoice.recipient_address.to_dict() + first_installment = order.payment_schedule[0] + credit_card = order.credit_card payload = backend.create_one_click_payment( - order, billing_address, credit_card.token + order, first_installment, credit_card.token, billing_address ) # - One click payment create has been called mock_payplug_create.assert_called_once_with( **{ - "amount": 12345, + "amount": 3704, "allow_save_card": False, "initiator": "PAYER", "payment_method": credit_card.token, "currency": "EUR", "billing": { - "email": "john.doe@acme.org", + "email": order.owner.email, "first_name": billing_address["first_name"], "last_name": billing_address["last_name"], "address1": billing_address["address"], @@ -327,7 +333,10 @@ def test_payment_backend_payplug_create_one_click_payment_not_authorized( }, "shipping": {"delivery_type": "DIGITAL_GOODS"}, "notification_url": "https://example.com/api/v1.0/payments/notifications", - "metadata": {"order_id": str(order.id)}, + "metadata": { + "order_id": str(order.id), + "installment_id": str(first_installment.get("id")), + }, } ) @@ -337,6 +346,7 @@ def test_payment_backend_payplug_create_one_click_payment_not_authorized( self.assertIsNotNone(payload["url"]) self.assertFalse(payload["is_paid"]) + @override_settings(JOANIE_PAYMENT_SCHEDULE_LIMITS={0: (30, 70)}) @mock.patch.object(payplug.Payment, "create") def test_payment_backend_payplug_create_one_click_payment( self, mock_payplug_create @@ -349,26 +359,28 @@ def test_payment_backend_payplug_create_one_click_payment( is_paid=True ) backend = PayplugBackend(self.configuration) - owner = UserFactory(email="john.doe@acme.org") - product = ProductFactory(price=D("123.45")) - order = OrderFactory(owner=owner, product=product) - billing_address = BillingAddressDictFactory() - credit_card = CreditCardFactory() + order = OrderGeneratorFactory( + state=ORDER_STATE_PENDING, + product__price=D("123.45"), + ) + billing_address = order.main_invoice.recipient_address.to_dict() + first_installment = order.payment_schedule[0] + credit_card = order.credit_card payload = backend.create_one_click_payment( - order, billing_address, credit_card.token + order, first_installment, credit_card.token, billing_address ) # - One click payment create has been called mock_payplug_create.assert_called_once_with( **{ - "amount": 12345, + "amount": 3704, "allow_save_card": False, "initiator": "PAYER", "payment_method": credit_card.token, "currency": "EUR", "billing": { - "email": "john.doe@acme.org", + "email": order.owner.email, "first_name": billing_address["first_name"], "last_name": billing_address["last_name"], "address1": billing_address["address"], @@ -378,7 +390,10 @@ def test_payment_backend_payplug_create_one_click_payment( }, "shipping": {"delivery_type": "DIGITAL_GOODS"}, "notification_url": "https://example.com/api/v1.0/payments/notifications", - "metadata": {"order_id": str(order.id)}, + "metadata": { + "order_id": str(order.id), + "installment_id": str(first_installment.get("id")), + }, } ) @@ -388,6 +403,7 @@ def test_payment_backend_payplug_create_one_click_payment( self.assertIsNotNone(payload["url"]) self.assertTrue(payload["is_paid"]) + @override_settings(JOANIE_PAYMENT_SCHEDULE_LIMITS={0: (30, 70)}) @mock.patch.object(payplug.Payment, "create") def test_payment_backend_payplug_create_one_click_payment_with_installment( self, mock_payplug_create @@ -400,58 +416,31 @@ def test_payment_backend_payplug_create_one_click_payment_with_installment( is_paid=True ) backend = PayplugBackend(self.configuration) - owner = UserFactory(email="john.doe@acme.org") - product = ProductFactory(price=D("123.45")) - order = OrderFactory( - owner=owner, - product=product, - payment_schedule=[ - { - "id": "d9356dd7-19a6-4695-b18e-ad93af41424a", - "amount": "200.00", - "due_date": "2024-01-17", - "state": enums.PAYMENT_STATE_PENDING, - }, - { - "id": "1932fbc5-d971-48aa-8fee-6d637c3154a5", - "amount": "300.00", - "due_date": "2024-02-17", - "state": enums.PAYMENT_STATE_PENDING, - }, - { - "id": "168d7e8c-a1a9-4d70-9667-853bf79e502c", - "amount": "300.00", - "due_date": "2024-03-17", - "state": enums.PAYMENT_STATE_PENDING, - }, - { - "id": "9fcff723-7be4-4b77-87c6-2865e000f879", - "amount": "199.99", - "due_date": "2024-04-17", - "state": enums.PAYMENT_STATE_PENDING, - }, - ], + order = OrderGeneratorFactory( + state=ORDER_STATE_PENDING, + product__price=D("123.45"), ) - billing_address = BillingAddressDictFactory() - credit_card = CreditCardFactory() + billing_address = order.main_invoice.recipient_address.to_dict() + credit_card = order.credit_card + first_installment = order.payment_schedule[0] payload = backend.create_one_click_payment( order, - billing_address, + order.payment_schedule[0], credit_card.token, - installment=order.payment_schedule[0], + billing_address, ) # - One click payment create has been called mock_payplug_create.assert_called_once_with( **{ - "amount": 20000, + "amount": 3704, "allow_save_card": False, "initiator": "PAYER", "payment_method": credit_card.token, "currency": "EUR", "billing": { - "email": "john.doe@acme.org", + "email": order.owner.email, "first_name": billing_address["first_name"], "last_name": billing_address["last_name"], "address1": billing_address["address"], @@ -463,7 +452,7 @@ def test_payment_backend_payplug_create_one_click_payment_with_installment( "notification_url": "https://example.com/api/v1.0/payments/notifications", "metadata": { "order_id": str(order.id), - "installment_id": "d9356dd7-19a6-4695-b18e-ad93af41424a", + "installment_id": str(first_installment["id"]), }, } ) @@ -731,21 +720,32 @@ def test_payment_backend_payplug_handle_notification_payment_mail(self, mock_tre When backend receives a payment success notification, success email is sent """ payment_id = "pay_00000" - product = ProductFactory() owner = UserFactory(language="en-us") - order = OrderFactory( - product=product, owner=owner, state=enums.ORDER_STATE_SUBMITTED - ) backend = PayplugBackend(self.configuration) - billing_address = BillingAddressDictFactory() - payplug_billing_address = billing_address.copy() + order = OrderGeneratorFactory( + state=ORDER_STATE_PENDING, + id="514070fe-c12c-48b8-97cf-5262708673a3", + owner=owner, + credit_card__is_main=True, + credit_card__initial_issuer_transaction_identifier="1", + product__price=D("123.45"), + ) + # Force the first installment id to match the stored request + first_installment = order.payment_schedule[0] + first_installment["id"] = "d9356dd7-19a6-4695-b18e-ad93af41424a" + order.save() + + payplug_billing_address = order.main_invoice.recipient_address.to_dict() payplug_billing_address["address1"] = payplug_billing_address["address"] del payplug_billing_address["address"] mock_treat.return_value = PayplugFactories.PayplugPaymentFactory( id=payment_id, amount=12345, billing=payplug_billing_address, - metadata={"order_id": str(order.id)}, + metadata={ + "order_id": str(order.id), + "installment_id": first_installment["id"], + }, is_paid=True, is_refunded=False, ) @@ -757,9 +757,7 @@ def test_payment_backend_payplug_handle_notification_payment_mail(self, mock_tre backend.handle_notification(request) # Email has been sent - self._check_order_validated_email_sent( - order.owner.email, order.owner.get_full_name(), order - ) + self._check_installment_paid_email_sent(order.owner.email, order) @mock.patch.object(BasePaymentBackend, "_do_on_payment_success") @mock.patch.object(payplug.notifications, "treat") @@ -892,3 +890,215 @@ def test_payment_backend_payplug_abort_payment_request_failed( "The server gave the following response: `Abort this payment is forbidden.`." ), ) + + @mock.patch.object(BasePaymentBackend, "_send_mail_refused_debit") + @mock.patch.object(payplug.notifications, "treat") + def test_payment_backend_payplug_payment_failure_on_installment_should_trigger_email_method( + self, mock_treat, mock_send_mail_refused_debit + ): + """ + When the backend receives a payment notification which mentions that the payment + debit has failed, the generic method `_do_on_payment_failure` should be called and + also call the method that is responsible to send an email to the user. + """ + backend = PayplugBackend(self.configuration) + payment_id = "pay_failure" + user = UserFactory( + first_name="John", + last_name="Doe", + language="en-us", + email="john.doe@acme.org", + ) + product = ProductFactory(price=D("999.99"), title="Product 1") + order = OrderGeneratorFactory( + state=ORDER_STATE_PENDING, + id="758c2570-a7af-4335-b091-340d0cc6e694", + owner=user, + product=product, + ) + # Force the first installment id to match the stored request + first_installment = order.payment_schedule[0] + first_installment["id"] = "d9356dd7-19a6-4695-b18e-ad93af41424a" + order.save() + + mock_treat.return_value = PayplugFactories.PayplugPaymentFactory( + id=payment_id, + failure=True, + metadata={ + "order_id": str(order.id), + "installment_id": "d9356dd7-19a6-4695-b18e-ad93af41424a", + }, + ) + + request = APIRequestFactory().post( + reverse("payment_webhook"), data={"id": payment_id}, format="json" + ) + + backend.handle_notification(request) + + mock_send_mail_refused_debit.assert_called_once_with( + order, "d9356dd7-19a6-4695-b18e-ad93af41424a" + ) + + @mock.patch.object(payplug.notifications, "treat") + def test_payment_backend_payplug_refused_installment_email_should_use_user_language_in_english( + self, mock_treat + ): + """ + When backend receives a payment notification which failed, the generic method + `_do_on_payment_failure` should be called and should send an email mentioning about + the refused debit on the installment in the user's preferred language that is English + in this case. + """ + backend = PayplugBackend(self.configuration) + payment_id = "pay_failure" + user = UserFactory( + first_name="John", + last_name="Doe", + language="en-us", + email="john.doe@acme.org", + ) + product = ProductFactory(price=D("999.99"), title="Product 1") + order = OrderGeneratorFactory( + state=ORDER_STATE_PENDING, + id="758c2570-a7af-4335-b091-340d0cc6e694", + owner=user, + product=product, + ) + # Force the first installment id to match the stored request + first_installment = order.payment_schedule[0] + first_installment["id"] = "d9356dd7-19a6-4695-b18e-ad93af41424a" + order.save() + + mock_treat.return_value = PayplugFactories.PayplugPaymentFactory( + id=payment_id, + failure=True, + metadata={ + "order_id": str(order.id), + "installment_id": "d9356dd7-19a6-4695-b18e-ad93af41424a", + }, + ) + + request = APIRequestFactory().post( + reverse("payment_webhook"), data={"id": payment_id}, format="json" + ) + + backend.handle_notification(request) + + email_content = " ".join(mail.outbox[0].body.split()) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to[0], "john.doe@acme.org") + self.assertIn( + "An installment debit has failed", + mail.outbox[0].subject, + ) + self.assertIn("Product 1", email_content) + + @mock.patch.object(payplug.notifications, "treat") + def test_payment_backend_payplug_refused_installment_email_should_use_user_language_in_french( + self, mock_treat + ): + """ + When the backend receives a payment notification which failed, the generic method + `_do_on_payment_failure` should be called and should send an email mentioning about + the refused debit on the installment in the user's preferred language that is + the French language in this case. + """ + backend = PayplugBackend(self.configuration) + payment_id = "pay_failure" + user = UserFactory( + first_name="John", + last_name="Doe", + language="fr-fr", + email="john.doe@acme.org", + ) + product = ProductFactory(price=D("999.99"), title="Product 1") + product.translations.create(language_code="fr-fr", title="Produit 1") + order = OrderGeneratorFactory( + state=ORDER_STATE_PENDING, + id="758c2570-a7af-4335-b091-340d0cc6e694", + owner=user, + product=product, + ) + # Force the first installment id to match the stored request + first_installment = order.payment_schedule[0] + first_installment["id"] = "d9356dd7-19a6-4695-b18e-ad93af41424a" + order.save() + + mock_treat.return_value = PayplugFactories.PayplugPaymentFactory( + id=payment_id, + failure=True, + metadata={ + "order_id": str(order.id), + "installment_id": "d9356dd7-19a6-4695-b18e-ad93af41424a", + }, + ) + + request = APIRequestFactory().post( + reverse("payment_webhook"), data={"id": payment_id}, format="json" + ) + + backend.handle_notification(request) + + email_content = " ".join(mail.outbox[0].body.split()) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to[0], "john.doe@acme.org") + self.assertIn("Produit 1", email_content) + + @override_settings( + LANGUAGES=( + ("en-us", ("English")), + ("fr-fr", ("French")), + ("de-de", ("German")), + ) + ) + @mock.patch.object(payplug.notifications, "treat") + def test_payment_backend_payplug_send_email_refused_installment_should_use_fallback_language( + self, mock_treat + ): + """ + When the backend receives a payment notification which failed, the generic method + `_do_on_payment_failure` should be called and should send an email with the fallback + language if the translation title does not exist into the user's preferred language. + In this case, the fallback language should be in English. + """ + backend = PayplugBackend(self.configuration) + payment_id = "pay_failure" + user = UserFactory( + first_name="John", + last_name="Doe", + language="de-de", + email="john.doe@acme.org", + ) + product = ProductFactory(price=D("1000.00"), title="Test Product 1") + product.translations.create(language_code="fr-fr", title="Test Produit 1") + order = OrderGeneratorFactory( + state=ORDER_STATE_PENDING, + id="758c2570-a7af-4335-b091-340d0cc6e694", + owner=user, + product=product, + ) + # Force the first installment id to match the stored request + first_installment = order.payment_schedule[0] + first_installment["id"] = "d9356dd7-19a6-4695-b18e-ad93af41424a" + order.save() + + mock_treat.return_value = PayplugFactories.PayplugPaymentFactory( + id=payment_id, + failure=True, + metadata={ + "order_id": str(order.id), + "installment_id": "d9356dd7-19a6-4695-b18e-ad93af41424a", + }, + ) + + request = APIRequestFactory().post( + reverse("payment_webhook"), data={"id": payment_id}, format="json" + ) + + backend.handle_notification(request) + + email_content = " ".join(mail.outbox[0].body.split()) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to[0], "john.doe@acme.org") + self.assertIn("Test Product 1", email_content) diff --git a/src/backend/joanie/tests/signature/backends/lex_persona/test_get_signature_state.py b/src/backend/joanie/tests/signature/backends/lex_persona/test_get_signature_state.py new file mode 100644 index 000000000..4c6b6f69e --- /dev/null +++ b/src/backend/joanie/tests/signature/backends/lex_persona/test_get_signature_state.py @@ -0,0 +1,441 @@ +"""Test suite for the Lex Persona Signature Backend `get_signature_state`""" + +from http import HTTPStatus + +from django.test import TestCase +from django.test.utils import override_settings + +import responses + +from joanie.signature import exceptions +from joanie.signature.backends import get_signature_backend + + +@override_settings( + JOANIE_SIGNATURE_BACKEND="joanie.signature.backends.lex_persona.LexPersonaBackend", + JOANIE_SIGNATURE_LEXPERSONA_BASE_URL="https://lex_persona.test01.com", + JOANIE_SIGNATURE_LEXPERSONA_CONSENT_PAGE_ID="cop_id_fake", + JOANIE_SIGNATURE_LEXPERSONA_SESSION_USER_ID="usr_id_fake", + JOANIE_SIGNATURE_LEXPERSONA_PROFILE_ID="sip_profile_id_fake", + JOANIE_SIGNATURE_LEXPERSONA_TOKEN="token_id_fake", + JOANIE_SIGNATURE_VALIDITY_PERIOD_IN_SECONDS=60 * 60 * 24 * 15, + JOANIE_SIGNATURE_TIMEOUT=3, +) +class LexPersonaBackendGetSignatureState(TestCase): + """ + Test suite for `get_signature_state` + """ + + @responses.activate + def test_backend_lex_persona_get_signature_state_when_nobody_has_signed_yet( + self, + ): + """ + Test that the method `get_signature_state` return that nobody has signed the document. + It should return the value False for the student and the organization in the dictionnary. + """ + backend = get_signature_backend() + workflow_id = "wfl_fake_id" + api_url = f"https://lex_persona.test01.com/api/workflows/{workflow_id}/" + response = { + "allowConsolidation": True, + "allowedCoManagerUsers": [], + "coManagerNotifiedEvents": [], + "created": 1726242320013, + "currentRecipientEmails": ["johndoe@acme.fr"], + "currentRecipientUsers": [], + "description": "1 rue de l'exemple, 75000 Paris", + "email": "johndoes@acme.fr", + "firstName": "John", + "groupId": "grp_fake_id", + "id": workflow_id, + "lastName": "Does", + "logs": [], + "name": "Test workflow signature", + "notifiedEvents": [ + "recipientFinished", + "workflowStopped", + "workflowFinished", + ], + "progress": 0, + "started": 1726242331317, + "steps": [ + { + "allowComments": False, + "hideAttachments": False, + "hideWorkflowRecipients": False, + "id": "stp_fake_id", + "invitePeriod": 86400000, + "isFinished": False, + "isStarted": True, + "logs": [ + {"created": 1726242331317, "operation": "start"}, + { + "created": 1726242331317, + "operation": "notifyWorkflowStarted", + }, + ], + "maxInvites": 0, + "recipients": [ + { + "consentPageId": "cop_fake_id", + "country": "FR", + "email": "johndoe@acme.org", + "firstName": "John Doe", + "lastName": ".", + "phoneNumber": "", + "preferredLocale": "fr", + } + ], + "requiredRecipients": 1, + "sendDownloadLink": True, + "stepType": "signature", + "validityPeriod": 86400000, + }, + { + "allowComments": False, + "hideAttachments": False, + "hideWorkflowRecipients": False, + "id": "stp_fake_id", + "invitePeriod": 86400000, + "isFinished": False, + "isStarted": False, + "logs": [], + "maxInvites": 0, + "recipients": [ + { + "consentPageId": "cop_fake_id", + "country": "FR", + "email": "janedoe@acme.org", + "firstName": "Jane Doe", + "lastName": ".", + "phoneNumber": "", + "preferredLocale": "fr", + } + ], + "requiredRecipients": 1, + "sendDownloadLink": True, + "stepType": "signature", + "validityPeriod": 86400000, + }, + ], + "tenantId": "ten_fake_id", + "updated": 1726242331422, + "userId": "usr_fake_id", + "viewAuthorizedGroups": ["grp_fake_id"], + "viewAuthorizedUsers": [], + "watchers": [], + "workflowStatus": "started", + } + responses.add( + responses.GET, + api_url, + json=response, + status=HTTPStatus.OK, + ) + + signature_state = backend.get_signature_state(reference_id=workflow_id) + + self.assertEqual(signature_state, {"student": False, "organization": False}) + + @responses.activate + def test_backend_lex_persona_get_signature_state_when_one_person_has_signed( + self, + ): + """ + Test that the method `get_signature_state` that the student has signed the document. + It should return the value True for the student and False for the organization + in the dictionary. + """ + backend = get_signature_backend() + workflow_id = "wfl_fake_id" + api_url = f"https://lex_persona.test01.com/api/workflows/{workflow_id}/" + + response = { + "allowConsolidation": True, + "allowedCoManagerUsers": [], + "coManagerNotifiedEvents": [], + "created": 1726235653238, + "currentRecipientEmails": [], + "currentRecipientUsers": [], + "description": "1 rue de l'exemple, 75000 Paris", + "email": "johndoe@acme.org", + "firstName": "John", + "groupId": "grp_fake_id", + "id": workflow_id, + "lastName": "Doe", + "logs": [], + "name": "Test Workflow Signature", + "notifiedEvents": [ + "recipientFinished", + "workflowStopped", + "workflowFinished", + ], + "progress": 50, + "started": 1726235671708, + "steps": [ + { + "allowComments": False, + "hideAttachments": False, + "hideWorkflowRecipients": False, + "id": "stp_fake_id", + "invitePeriod": 86400000, + "isFinished": True, + "isStarted": True, + "logs": [ + {"created": 1726235671708, "operation": "start"}, + { + "created": 1726235671708, + "operation": "notifyWorkflowStarted", + }, + { + "created": 1726235727060, + "evidenceId": "evi_serversealingiframe_fake_id", + "operation": "sign", + "recipientEmail": "johndoes@acme.org", + }, + { + "created": 1726235727060, + "operation": "notifyRecipientFinished", + "recipientEmail": "johndoes@acme.org", + }, + ], + "maxInvites": 0, + "recipients": [ + { + "consentPageId": "cop_fake_id", + "country": "FR", + "email": "johndoes@acme.org", + "firstName": "John Doe", + "lastName": ".", + "phoneNumber": "", + "preferredLocale": "fr", + } + ], + "requiredRecipients": 1, + "sendDownloadLink": True, + "stepType": "signature", + "validityPeriod": 86400000, + }, + { + "allowComments": False, + "hideAttachments": False, + "hideWorkflowRecipients": False, + "id": "stp_fake_id", + "invitePeriod": 86400000, + "isFinished": False, + "isStarted": True, + "logs": [], + "maxInvites": 0, + "recipients": [ + { + "consentPageId": "cop_fake_id", + "country": "FR", + "email": "janedoe@acme.fr", + "firstName": "Jane Doe", + "lastName": ".", + "phoneNumber": "", + "preferredLocale": "fr", + } + ], + "requiredRecipients": 1, + "sendDownloadLink": True, + "stepType": "signature", + "validityPeriod": 86400000, + }, + ], + "tenantId": "ten_fake_id", + "updated": 1726237384491, + "userId": "usr_fake_id", + "viewAuthorizedGroups": ["grp_fake_id"], + "viewAuthorizedUsers": [], + "watchers": [], + "workflowStatus": "started", + } + + responses.add( + responses.GET, + api_url, + json=response, + status=HTTPStatus.OK, + ) + + signature_state = backend.get_signature_state(reference_id=workflow_id) + + self.assertEqual(signature_state, {"student": True, "organization": False}) + + @responses.activate + def test_backend_lex_persona_get_signature_state_all_signatories_have_signed( + self, + ): + """ + Test that the method `get_signature_state` that both have signed the document. + It should return the value True for the student and the organization in the dictionary. + """ + backend = get_signature_backend() + workflow_id = "wfl_fake_id" + api_url = f"https://lex_persona.test01.com/api/workflows/{workflow_id}/" + response = { + "allowConsolidation": True, + "allowedCoManagerUsers": [], + "coManagerNotifiedEvents": [], + "created": 1726235653238, + "currentRecipientEmails": [], + "currentRecipientUsers": [], + "description": "1 rue de l'exemple, 75000 Paris", + "email": "johndoes@acme.org", + "firstName": "John", + "groupId": "grp_fake_id", + "id": "wfl_fake_id", + "lastName": "Does", + "logs": [], + "name": "Test workflow signature", + "notifiedEvents": [ + "recipientFinished", + "workflowStopped", + "workflowFinished", + ], + "progress": 100, + "started": 1726235671708, + "steps": [ + { + "allowComments": False, + "hideAttachments": False, + "hideWorkflowRecipients": False, + "id": "stp_fake_id", + "invitePeriod": 86400000, + "isFinished": True, + "isStarted": True, + "logs": [ + {"created": 1726235671708, "operation": "start"}, + { + "created": 1726235671708, + "operation": "notifyWorkflowStarted", + }, + { + "created": 1726235727060, + "evidenceId": "evi_serversealingiframe_fake_id", + "operation": "sign", + "recipientEmail": "johndoe@acme.org", + }, + { + "created": 1726235727060, + "operation": "notifyRecipientFinished", + "recipientEmail": "johndoe@acme.org", + }, + ], + "maxInvites": 0, + "recipients": [ + { + "consentPageId": "cop_fake_id", + "country": "FR", + "email": "johndoe@acme.org", + "firstName": "John Doe", + "lastName": ".", + "phoneNumber": "", + "preferredLocale": "fr", + } + ], + "requiredRecipients": 1, + "sendDownloadLink": True, + "stepType": "signature", + "validityPeriod": 86400000, + }, + { + "allowComments": False, + "hideAttachments": False, + "hideWorkflowRecipients": False, + "id": "stp_fake_id", + "invitePeriod": 86400000, + "isFinished": True, + "isStarted": True, + "logs": [ + {"created": 1726235727082, "operation": "start"}, + { + "created": 1726237384315, + "evidenceId": "evi_serversealingiframe_fake_id", + "operation": "sign", + "recipientEmail": "janedoe@acme.org", + }, + { + "created": 1726237384315, + "operation": "notifyRecipientFinished", + "recipientEmail": "janedoe@acme.org", + }, + { + "created": 1726237384315, + "operation": "notifyWorkflowFinished", + }, + ], + "maxInvites": 0, + "recipients": [ + { + "consentPageId": "cop_fake_id", + "country": "FR", + "email": "janedoe@acme.org", + "firstName": "Jane Doe", + "lastName": ".", + "phoneNumber": "", + "preferredLocale": "fr", + } + ], + "requiredRecipients": 1, + "sendDownloadLink": True, + "stepType": "signature", + "validityPeriod": 86400000, + }, + ], + "tenantId": "ten_fake_id", + "updated": 1726237384491, + "userId": "usr_fake_id", + "viewAuthorizedGroups": ["grp_fake_id"], + "viewAuthorizedUsers": [], + "watchers": [], + "workflowStatus": "finished", + } + + responses.add( + responses.GET, + api_url, + json=response, + status=HTTPStatus.OK, + ) + + signature_state = backend.get_signature_state(reference_id=workflow_id) + + self.assertEqual(signature_state, {"student": True, "organization": True}) + + @responses.activate + def test_backend_lex_persona_get_signature_state_returns_not_found( + self, + ): + """ + Test that the method `get_signature_state` should return a status code + NOT_FOUND (404) because the reference does not exist at the signature provider. + """ + backend = get_signature_backend() + workflow_id = "wfl_fake_id_not_exist" + api_url = f"https://lex_persona.test01.com/api/workflows/{workflow_id}/" + response = { + "status": 404, + "error": "Not Found", + "message": "The specified workflow can not be found.", + "requestId": "2a72", + "code": "WorkflowNotFound", + } + + responses.add( + responses.GET, + api_url, + json=response, + status=HTTPStatus.NOT_FOUND, + ) + + with self.assertRaises(exceptions.SignatureProcedureNotFound) as context: + backend.get_signature_state(reference_id=workflow_id) + + self.assertEqual( + str(context.exception), + "Lex Persona: Unable to retrieve the signature procedure the reference " + "does not exist wfl_fake_id_not_exist", + ) diff --git a/src/backend/joanie/tests/signature/backends/lex_persona/test_submit_for_signature.py b/src/backend/joanie/tests/signature/backends/lex_persona/test_submit_for_signature.py index 9ae6f91a9..42970cec4 100644 --- a/src/backend/joanie/tests/signature/backends/lex_persona/test_submit_for_signature.py +++ b/src/backend/joanie/tests/signature/backends/lex_persona/test_submit_for_signature.py @@ -34,7 +34,7 @@ class LexPersonaBackendSubmitForSignatureTestCase(TestCase): def test_submit_for_signature_success(self): """valid test submit for signature""" user = factories.UserFactory(email="johnnydo@example.fr") - order = factories.OrderFactory(owner=user, state=enums.ORDER_STATE_VALIDATED) + order = factories.OrderFactory(owner=user, state=enums.ORDER_STATE_COMPLETED) accesses = factories.UserOrganizationAccessFactory.create_batch( 3, organization=order.organization, role="owner" ) @@ -281,7 +281,7 @@ def test_submit_for_signature_create_worklow_failed(self): """ user = factories.UserFactory(email="johnnydo@example.fr") - order = factories.OrderFactory(owner=user, state=enums.ORDER_STATE_VALIDATED) + order = factories.OrderFactory(owner=user, state=enums.ORDER_STATE_COMPLETED) factories.UserOrganizationAccessFactory.create_batch( 3, organization=order.organization, role="owner" ) @@ -331,7 +331,7 @@ def test_submit_for_signature_upload_file_failed(self): Upload Document Failed. """ user = factories.UserFactory(email="johnnydo@example.fr") - order = factories.OrderFactory(owner=user, state=enums.ORDER_STATE_VALIDATED) + order = factories.OrderFactory(owner=user, state=enums.ORDER_STATE_COMPLETED) factories.UserOrganizationAccessFactory.create_batch( 3, organization=order.organization, role="owner" ) @@ -460,7 +460,7 @@ def test_submit_for_signature_start_procedure_failed(self): raise the exception Start Signature Procedure Failed. """ user = factories.UserFactory(email="johnnydo@example.fr") - order = factories.OrderFactory(owner=user, state=enums.ORDER_STATE_VALIDATED) + order = factories.OrderFactory(owner=user, state=enums.ORDER_STATE_COMPLETED) factories.UserOrganizationAccessFactory.create_batch( 3, organization=order.organization, role="owner" ) @@ -636,7 +636,7 @@ def test_submit_for_signature_create_worklow_failed_because_no_organization_owne and an error must be raised. """ user = factories.UserFactory(email="johnnydo@example.fr") - order = factories.OrderFactory(owner=user, state=enums.ORDER_STATE_VALIDATED) + order = factories.OrderFactory(owner=user, state=enums.ORDER_STATE_COMPLETED) file_bytes = b"Some fake content" title = "Contract Definition" lex_persona_backend = get_signature_backend() diff --git a/src/backend/joanie/tests/signature/backends/lex_persona/test_update_organization_signatories.py b/src/backend/joanie/tests/signature/backends/lex_persona/test_update_organization_signatories.py index 1a9fe5056..db6b45b02 100644 --- a/src/backend/joanie/tests/signature/backends/lex_persona/test_update_organization_signatories.py +++ b/src/backend/joanie/tests/signature/backends/lex_persona/test_update_organization_signatories.py @@ -38,7 +38,7 @@ def test_backend_lex_persona_update_signatories_success(self): user = factories.UserFactory(email="johndoe@example.fr") order = factories.OrderFactory( owner=user, - state=enums.ORDER_STATE_VALIDATED, + state=enums.ORDER_STATE_COMPLETED, product__contract_definition=factories.ContractDefinitionFactory(), ) factories.ContractFactory( @@ -260,7 +260,7 @@ def test_backend_lex_persona_update_signatories_with_student_and_organization(se ) order = factories.OrderFactory( owner=user, - state=enums.ORDER_STATE_VALIDATED, + state=enums.ORDER_STATE_COMPLETED, product__contract_definition=factories.ContractDefinitionFactory(), ) factories.ContractFactory( @@ -491,7 +491,7 @@ def test_backend_lex_persona_update_signatories_with_wrong_reference_id( user = factories.UserFactory(email="johndoe@example.fr") order = factories.OrderFactory( owner=user, - state=enums.ORDER_STATE_VALIDATED, + state=enums.ORDER_STATE_COMPLETED, product__contract_definition=factories.ContractDefinitionFactory(), ) factories.ContractFactory( diff --git a/src/backend/joanie/tests/signature/test_backend_signature_base.py b/src/backend/joanie/tests/signature/test_backend_signature_base.py index 8bf773664..767576221 100644 --- a/src/backend/joanie/tests/signature/test_backend_signature_base.py +++ b/src/backend/joanie/tests/signature/test_backend_signature_base.py @@ -2,7 +2,6 @@ import random from datetime import timedelta -from unittest import mock from django.core.exceptions import ValidationError from django.test import TestCase @@ -73,85 +72,34 @@ def test_backend_signature_base_backend_get_setting(self): self.assertEqual(consent_page_key_setting, "fake_cop_id") @override_settings( - JOANIE_SIGNATURE_BACKEND=random.choice( - [ - "joanie.signature.backends.base.BaseSignatureBackend", - "joanie.signature.backends.dummy.DummySignatureBackend", - ] - ) + JOANIE_SIGNATURE_BACKEND="joanie.signature.backends.dummy.DummySignatureBackend" ) - @mock.patch("joanie.core.models.Order.enroll_user_to_course_run") - def test_backend_signature_base_backend_confirm_student_signature( - self, _mock_enroll_user - ): + def test_backend_signature_base_backend_confirm_student_signature(self): """ This test verifies that the `confirm_student_signature` method updates the contract with a timestamps for the field 'student_signed_on', and it should not set 'None' to the field 'submitted_for_signature_on'. - Furthermore, it should call the method - `enroll_user_to_course_run` on the contract's order. In this way, when user has signed - its contract, it should be enrolled to courses with only one course run. + Furthermore, it should update the order state. """ - user = factories.UserFactory() - order = factories.OrderFactory( - owner=user, - product__contract_definition=factories.ContractDefinitionFactory(), - state=enums.ORDER_STATE_VALIDATED, - ) - 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(), + order = factories.OrderGeneratorFactory( + state=enums.ORDER_STATE_TO_SIGN, + product__price=0, ) + contract = order.contract backend = get_signature_backend() - backend.confirm_student_signature(reference="wfl_fake_dummy_id") - - contract.refresh_from_db() - self.assertIsNotNone(contract.submitted_for_signature_on) - self.assertIsNotNone(contract.student_signed_on) - - # contract.order.enroll_user_to_course should have been called once - _mock_enroll_user.assert_called_once() - - @mock.patch( - "joanie.core.models.Order.enroll_user_to_course_run", side_effect=Exception - ) - def test_backend_signature_base_backend_confirm_student_signature_with_auto_enroll_failure( - self, mock_enroll_user - ): - """ - If the automatic enrollment fails, the `confirm_student_signature` method - should log an error and continue the process. - """ - user = factories.UserFactory() - order = factories.OrderFactory( - owner=user, - product__contract_definition=factories.ContractDefinitionFactory(), - state=enums.ORDER_STATE_VALIDATED, - ) - 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(), + order.submit_for_signature(order.owner) + backend.confirm_student_signature( + reference=contract.signature_backend_reference ) - backend = get_signature_backend() - - backend.confirm_student_signature(reference="wfl_fake_dummy_id") contract.refresh_from_db() self.assertIsNotNone(contract.submitted_for_signature_on) self.assertIsNotNone(contract.student_signed_on) - # contract.order.enroll_user_to_course should have been called once - mock_enroll_user.assert_called_once() + order.refresh_from_db() + self.assertEqual(order.state, enums.ORDER_STATE_COMPLETED) @override_settings( JOANIE_SIGNATURE_BACKEND=random.choice( @@ -194,12 +142,7 @@ def test_backend_signature_base_backend_confirm_student_signature_but_validity_p ) @override_settings( - JOANIE_SIGNATURE_BACKEND=random.choice( - [ - "joanie.signature.backends.base.BaseSignatureBackend", - "joanie.signature.backends.dummy.DummySignatureBackend", - ] - ) + JOANIE_SIGNATURE_BACKEND="joanie.signature.backends.dummy.DummySignatureBackend" ) def test_backend_signature_base_backend_reset_contract(self): """ @@ -207,22 +150,14 @@ def test_backend_signature_base_backend_reset_contract(self): for the fields : 'context', 'definition_checksum', 'submitted_for_signature_on', and 'signature_backend_reference'. """ - user = factories.UserFactory() - order = factories.OrderFactory( - owner=user, - product__contract_definition=factories.ContractDefinitionFactory(), - ) - 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(), + order = factories.OrderGeneratorFactory( + state=enums.ORDER_STATE_SIGNING, + product__price=0, ) + contract = order.contract backend = get_signature_backend() - backend.reset_contract(reference="wfl_fake_dummy_id") + backend.reset_contract(reference=contract.signature_backend_reference) contract.refresh_from_db() self.assertIsNone(contract.student_signed_on) @@ -230,3 +165,26 @@ def test_backend_signature_base_backend_reset_contract(self): self.assertIsNone(contract.context) self.assertIsNone(contract.definition_checksum) self.assertIsNone(contract.signature_backend_reference) + order.refresh_from_db() + self.assertEqual(order.state, enums.ORDER_STATE_TO_SIGN) + + @override_settings( + JOANIE_SIGNATURE_BACKEND="joanie.signature.backends.base.BaseSignatureBackend", + ) + def test_backend_signature_base_raise_not_implemented_error_get_signature_state( + self, + ): + """ + Base backend signature provider should raise NotImplementedError for the method + `get_signature_state`. + """ + backend = get_signature_backend() + + with self.assertRaises(NotImplementedError) as context: + backend.get_signature_state(reference_id="123") + + self.assertEqual( + str(context.exception), + "subclasses of BaseSignatureBackend must provide a " + "get_signature_state() method.", + ) diff --git a/src/backend/joanie/tests/signature/test_backend_signature_dummy.py b/src/backend/joanie/tests/signature/test_backend_signature_dummy.py index 846bb06b6..a72942c02 100644 --- a/src/backend/joanie/tests/signature/test_backend_signature_dummy.py +++ b/src/backend/joanie/tests/signature/test_backend_signature_dummy.py @@ -1,14 +1,17 @@ """Test suite of the DummySignatureBackend""" +import json import random from io import BytesIO from django.core import mail from django.core.exceptions import ValidationError from django.test import TestCase +from django.urls import reverse from django.utils import timezone as django_timezone from pdfminer.high_level import extract_text as pdf_extract_text +from rest_framework.test import APIRequestFactory from joanie.core import enums, factories from joanie.payment.factories import InvoiceFactory @@ -83,14 +86,15 @@ def test_backend_dummy_signature_get_signature_invitation_link(self): """ Dummy backend instance get signature invitation link method in order to get the invitation to sign link in return. - Once we call the method for the invitation link, it should trigger an email with a dummy - link to download the file and call the handle_notification method. """ backend = DummySignatureBackend() - expected_substring = "https://dummysignaturebackend.fr/?requestToken=" reference, file_hash = backend.submit_for_signature( "title definition 1", b"file_bytes", {} ) + expected_substring = ( + f"https://dummysignaturebackend.fr/?reference={reference}" + f"&eventTarget=signed" + ) contract = factories.ContractFactory( signature_backend_reference=reference, definition_checksum=file_hash, @@ -106,10 +110,44 @@ def test_backend_dummy_signature_get_signature_invitation_link(self): self.assertIn(expected_substring, response) contract.refresh_from_db() + self.assertIsNone(contract.student_signed_on) + self.assertIsNotNone(contract.submitted_for_signature_on) + + def test_backend_dummy_signature_get_signature_invitation_link_with_learner_signed( + self, + ): + """ + Dummy backend instance get signature invitation link method in order to get the invitation + to sign link in return. If the learner has already signed the contract, the link should + target the organization signature. + """ + backend = DummySignatureBackend() + reference, file_hash = backend.submit_for_signature( + "title definition 1", b"file_bytes", {} + ) + contract = factories.ContractFactory( + signature_backend_reference=reference, + definition_checksum=file_hash, + submitted_for_signature_on=django_timezone.now(), + student_signed_on=django_timezone.now(), + context="a small context content", + ) + + response = backend.get_signature_invitation_link( + recipient_email="student_do@example.fr", + reference_ids=[reference], + ) + + expected_substring = ( + f"https://dummysignaturebackend.fr/?reference={reference}" + "&eventTarget=finished" + ) + self.assertIn(expected_substring, response) + + contract.refresh_from_db() + self.assertIsNone(contract.organization_signed_on) self.assertIsNotNone(contract.student_signed_on) self.assertIsNotNone(contract.submitted_for_signature_on) - # Check that an email has been sent - self._check_signature_completed_email_sent("student_do@example.fr") def test_backend_dummy_signature_get_signature_invitation_link_for_organization( self, @@ -117,13 +155,9 @@ def test_backend_dummy_signature_get_signature_invitation_link_for_organization( """ Dummy backend instance get_signature_invitation_link method should return an invitation link to sign the contract. - - If the contract has been signed by the student, calling this method should send - an email to the organization signatory and call the handle notification method - to mimic the fact that the organization has signed the contract. """ backend = DummySignatureBackend() - expected_substring = "https://dummysignaturebackend.fr/?requestToken=" + expected_substring = "https://dummysignaturebackend.fr/?reference=" reference, file_hash = backend.submit_for_signature( "title definition 1", b"file_bytes", {} ) @@ -144,10 +178,8 @@ def test_backend_dummy_signature_get_signature_invitation_link_for_organization( contract.refresh_from_db() self.assertIsNotNone(contract.student_signed_on) - self.assertIsNotNone(contract.organization_signed_on) - self.assertIsNone(contract.submitted_for_signature_on) - # Check that an email has been sent - self._check_signature_completed_email_sent("student_do@example.fr") + self.assertIsNone(contract.organization_signed_on) + self.assertIsNotNone(contract.submitted_for_signature_on) def test_backend_dummy_signature_get_signature_invitation_link_with_several_contracts( self, @@ -155,13 +187,9 @@ def test_backend_dummy_signature_get_signature_invitation_link_with_several_cont """ Dummy backend instance get_signature_invitation_link method should return an invitation link to sign several contracts. - - For each contract implied, calling this method should send - an email to the organization signatory and call the handle notification method - to mimic the fact that the organization has signed the contract. """ backend = DummySignatureBackend() - expected_substring = "https://dummysignaturebackend.fr/?requestToken=" + expected_substring = "https://dummysignaturebackend.fr/?reference=" signature_data = [ backend.submit_for_signature("title definition 1", b"file_bytes", {}), backend.submit_for_signature("title definition 2", b"file_bytes", {}), @@ -186,7 +214,7 @@ def test_backend_dummy_signature_get_signature_invitation_link_with_several_cont for contract in contracts: contract.refresh_from_db() - self.assertIsNotNone(contract.student_signed_on) + self.assertIsNone(contract.student_signed_on) self.assertIsNotNone(contract.submitted_for_signature_on) def test_backend_dummy_signature_delete_signature_procedure(self): @@ -249,10 +277,15 @@ def test_backend_dummy_signature_handle_notification_signed_event(self): submitted_for_signature_on=django_timezone.now(), context="a small context content", ) - mocked_request = { - "event_type": "signed", - "reference": reference, - } + mocked_request = APIRequestFactory().post( + reverse("webhook_signature"), + data={ + "event_type": "signed", + "reference": reference, + }, + format="json", + ) + mocked_request.data = json.loads(mocked_request.body.decode("utf-8")) backend.handle_notification(mocked_request) @@ -277,10 +310,15 @@ def test_backend_dummy_signature_handle_notification_finished_event(self): student_signed_on=django_timezone.now(), context="a small context content", ) - mocked_request = { - "event_type": "finished", - "reference": reference, - } + mocked_request = APIRequestFactory().post( + reverse("webhook_signature"), + data={ + "event_type": "finished", + "reference": reference, + }, + format="json", + ) + mocked_request.data = json.loads(mocked_request.body.decode("utf-8")) backend.handle_notification(mocked_request) @@ -308,10 +346,15 @@ def test_backend_dummy_signature_handle_notification_wrong_event_type(self): event_type = random.choice( ["started", "stopped", "commented", "untracked_event"] ) - mocked_request = { - "event_type": event_type, - "reference": reference, - } + mocked_request = APIRequestFactory().post( + reverse("webhook_signature"), + data={ + "event_type": event_type, + "reference": reference, + }, + format="json", + ) + mocked_request.data = json.loads(mocked_request.body.decode("utf-8")) with self.assertRaises(ValidationError) as context: backend.handle_notification(mocked_request) @@ -387,7 +430,7 @@ def test_backend_dummy_update_organization_signatories_already_fully_signed(self """ backend = DummySignatureBackend() order = factories.OrderFactory( - state=enums.ORDER_STATE_VALIDATED, + state=enums.ORDER_STATE_COMPLETED, product__contract_definition=factories.ContractDefinitionFactory(), ) contract = factories.ContractFactory( @@ -417,7 +460,7 @@ def test_backend_dummy_update_organization_signatories(self): """ backend = DummySignatureBackend() order = factories.OrderFactory( - state=enums.ORDER_STATE_VALIDATED, + state=enums.ORDER_STATE_COMPLETED, product__contract_definition=factories.ContractDefinitionFactory(), ) contract = factories.ContractFactory( @@ -453,3 +496,84 @@ def test_backend_dummy_update_organization_signatories_order_without_contract(se str(context.exception.message), "The reference fake_signature_reference does not exist.", ) + + def test_backend_dummy_get_signature_state(self): + """ + Dummy backend instance should return the value of how many people have signed the + document. It returns a dictionary with boolean value that gives us the information + if the student has signed and the organization. + """ + backend = DummySignatureBackend() + order = factories.OrderFactory( + state=enums.ORDER_STATE_TO_SIGN, + product__contract_definition=factories.ContractDefinitionFactory(), + ) + contract = 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=django_timezone.now(), + student_signed_on=None, + organization_signed_on=None, + ) + + signature_state = backend.get_signature_state( + contract.signature_backend_reference + ) + + self.assertEqual(signature_state, {"student": False, "organization": False}) + + contract.student_signed_on = django_timezone.now() + contract.save() + + signature_state = backend.get_signature_state( + contract.signature_backend_reference + ) + + self.assertEqual(signature_state, {"student": True, "organization": False}) + + contract.organization_signed_on = django_timezone.now() + contract.submitted_for_signature_on = None + contract.save() + + signature_state = backend.get_signature_state( + contract.signature_backend_reference + ) + + self.assertEqual(signature_state, {"student": True, "organization": True}) + + def test_backend_dummy_get_signature_state_with_non_existing_reference_id( + self, + ): + """ + Dummy backend instance should not return a dictionary if the passed `reference_id` + is not attached to any contract and raise a `ValidationError`. + """ + backend = DummySignatureBackend() + + with self.assertRaises(ValidationError) as context: + backend.get_signature_state(reference_id="wfl_fake_dummy_id_does_not_exist") + + self.assertEqual( + str(context.exception.message), + "Contract with reference id wfl_fake_dummy_id_does_not_exist does not exist.", + ) + + def test_backend_dummy_get_signature_state_with_wrong_format_reference_id( + self, + ): + """ + Dummy backend instance should raise a `ValidationError` if the reference_id + has the wrong format for the Dummy Backend. + """ + backend = DummySignatureBackend() + + with self.assertRaises(ValidationError) as context: + backend.get_signature_state(reference_id="fake_dummy_id_does_not_exist") + + self.assertEqual( + str(context.exception.message), + "The reference does not exist: fake_dummy_id_does_not_exist.", + ) diff --git a/src/backend/joanie/tests/signature/test_commands_generate_zip_archive_of_contracts.py b/src/backend/joanie/tests/signature/test_commands_generate_zip_archive_of_contracts.py index ea3a4821d..e6f4a2817 100644 --- a/src/backend/joanie/tests/signature/test_commands_generate_zip_archive_of_contracts.py +++ b/src/backend/joanie/tests/signature/test_commands_generate_zip_archive_of_contracts.py @@ -179,7 +179,8 @@ def test_commands_generate_zip_archive_contracts_fails_because_user_does_not_hav owner=user, product=relation.product, course=relation.course, - state=enums.ORDER_STATE_VALIDATED, + state=enums.ORDER_STATE_COMPLETED, + main_invoice=payment_factories.InvoiceFactory(), ) context = contract_definition.generate_document_context( order.product.contract_definition, user, order @@ -243,7 +244,8 @@ def test_commands_generate_zip_archive_contracts_aborts_because_no_signed_contra owner=user, product=relation.product, course=relation.course, - state=enums.ORDER_STATE_VALIDATED, + state=enums.ORDER_STATE_COMPLETED, + main_invoice=payment_factories.InvoiceFactory(), ) context = contract_definition.generate_document_context( order.product.contract_definition, user, order @@ -307,7 +309,7 @@ def test_commands_generate_zip_archive_contracts_success_with_courseproductrelat owner=user, product=relation.product, course=relation.course, - state=enums.ORDER_STATE_VALIDATED, + state=enums.ORDER_STATE_COMPLETED, main_invoice=payment_factories.InvoiceFactory( recipient_address__address="1 Rue de L'Exemple", recipient_address__postcode=75000, @@ -401,7 +403,7 @@ def test_commands_generate_zip_archive_contracts_success_with_organization_param owner=user, product=relation.product, course=relation.course, - state=enums.ORDER_STATE_VALIDATED, + state=enums.ORDER_STATE_COMPLETED, main_invoice=payment_factories.InvoiceFactory( recipient_address__address="1 Rue de L'Exemple", recipient_address__postcode=75000, @@ -499,7 +501,8 @@ def test_commands_generate_zip_archive_with_parameter_zip_uuid_is_not_a_uuid_str owner=user, product=relation.product, course=relation.course, - state=enums.ORDER_STATE_VALIDATED, + state=enums.ORDER_STATE_COMPLETED, + main_invoice=payment_factories.InvoiceFactory(), ) context = contract_definition.generate_document_context( order.product.contract_definition, user, order diff --git a/src/backend/joanie/tests/swagger/admin-swagger.json b/src/backend/joanie/tests/swagger/admin-swagger.json index 67e60b33b..de2fe6326 100644 --- a/src/backend/joanie/tests/swagger/admin-swagger.json +++ b/src/backend/joanie/tests/swagger/admin-swagger.json @@ -2883,6 +2883,7 @@ "schema": { "type": "string", "enum": [ + "assigned", "canceled", "completed", "draft", @@ -2890,11 +2891,12 @@ "no_payment", "pending", "pending_payment", - "submitted", - "validated" + "signing", + "to_save_payment_method", + "to_sign" ] }, - "description": "* `draft` - Draft\n* `submitted` - Submitted\n* `pending` - Pending\n* `canceled` - Canceled\n* `validated` - Validated\n* `pending_payment` - Pending payment\n* `failed_payment` - Failed payment\n* `no_payment` - No payment\n* `completed` - Completed" + "description": "* `draft` - Draft\n* `assigned` - Assigned\n* `to_save_payment_method` - To save payment method\n* `to_sign` - To sign\n* `signing` - Signing\n* `pending` - Pending\n* `canceled` - Canceled\n* `pending_payment` - Pending payment\n* `failed_payment` - Failed payment\n* `no_payment` - No payment\n* `completed` - Completed" } ], "tags": [ @@ -5191,6 +5193,43 @@ "title" ] }, + "AdminCreditCard": { + "type": "object", + "description": "Read only Serializer for CreditCard model.", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "readOnly": true, + "description": "primary key for the record as UUID" + }, + "brand": { + "type": "string", + "readOnly": true, + "nullable": true + }, + "expiration_month": { + "type": "integer", + "readOnly": true + }, + "expiration_year": { + "type": "integer", + "readOnly": true + }, + "last_numbers": { + "type": "string", + "readOnly": true, + "title": "Last 4 numbers" + } + }, + "required": [ + "brand", + "expiration_month", + "expiration_year", + "id", + "last_numbers" + ] + }, "AdminEnrollment": { "type": "object", "description": "Serializer for Enrollment model", @@ -5506,10 +5545,20 @@ "main_invoice": { "$ref": "#/components/schemas/AdminInvoice" }, - "has_consent_to_terms": { - "type": "boolean", - "readOnly": true, - "description": "User has consented to the platform terms and conditions." + "payment_schedule": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AdminOrderPayment" + }, + "readOnly": true + }, + "credit_card": { + "allOf": [ + { + "$ref": "#/components/schemas/AdminCreditCard" + } + ], + "readOnly": true } }, "required": [ @@ -5517,13 +5566,14 @@ "contract", "course", "created_on", + "credit_card", "enrollment", - "has_consent_to_terms", "id", "main_invoice", "order_group", "organization", "owner", + "payment_schedule", "product", "state", "total", @@ -5788,6 +5838,51 @@ "total_currency" ] }, + "AdminOrderPayment": { + "type": "object", + "description": "Serializer for the order payment", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "amount": { + "type": "number", + "format": "double", + "maximum": 10000000, + "minimum": 0.0, + "exclusiveMaximum": true + }, + "currency": { + "type": "string", + "description": "Return the code of currency used by the instance", + "readOnly": true + }, + "due_date": { + "type": "string", + "format": "date" + }, + "state": { + "$ref": "#/components/schemas/AdminOrderPaymentStateEnum" + } + }, + "required": [ + "amount", + "currency", + "due_date", + "id", + "state" + ] + }, + "AdminOrderPaymentStateEnum": { + "enum": [ + "pending", + "paid", + "refused" + ], + "type": "string", + "description": "* `pending` - Pending\n* `paid` - Paid\n* `refused` - Refused" + }, "AdminOrganization": { "type": "object", "description": "Serializer for Organization model.", @@ -6913,17 +7008,19 @@ "OrderStateEnum": { "enum": [ "draft", - "submitted", + "assigned", + "to_save_payment_method", + "to_sign", + "signing", "pending", "canceled", - "validated", "pending_payment", "failed_payment", "no_payment", "completed" ], "type": "string", - "description": "* `draft` - Draft\n* `submitted` - Submitted\n* `pending` - Pending\n* `canceled` - Canceled\n* `validated` - Validated\n* `pending_payment` - Pending payment\n* `failed_payment` - Failed payment\n* `no_payment` - No payment\n* `completed` - Completed" + "description": "* `draft` - Draft\n* `assigned` - Assigned\n* `to_save_payment_method` - To save payment method\n* `to_sign` - To sign\n* `signing` - Signing\n* `pending` - Pending\n* `canceled` - Canceled\n* `pending_payment` - Pending payment\n* `failed_payment` - Failed payment\n* `no_payment` - No payment\n* `completed` - Completed" }, "OrganizationAccessRoleChoiceEnum": { "enum": [ diff --git a/src/backend/joanie/tests/swagger/swagger.json b/src/backend/joanie/tests/swagger/swagger.json index 66d4adb25..de2cb0d83 100644 --- a/src/backend/joanie/tests/swagger/swagger.json +++ b/src/backend/joanie/tests/swagger/swagger.json @@ -2770,6 +2770,7 @@ "items": { "type": "string", "enum": [ + "assigned", "canceled", "completed", "draft", @@ -2777,12 +2778,13 @@ "no_payment", "pending", "pending_payment", - "submitted", - "validated" + "signing", + "to_save_payment_method", + "to_sign" ] } }, - "description": "* `draft` - Draft\n* `submitted` - Submitted\n* `pending` - Pending\n* `canceled` - Canceled\n* `validated` - Validated\n* `pending_payment` - Pending payment\n* `failed_payment` - Failed payment\n* `no_payment` - No payment\n* `completed` - Completed", + "description": "* `draft` - Draft\n* `assigned` - Assigned\n* `to_save_payment_method` - To save payment method\n* `to_sign` - To sign\n* `signing` - Signing\n* `pending` - Pending\n* `canceled` - Canceled\n* `pending_payment` - Pending payment\n* `failed_payment` - Failed payment\n* `no_payment` - No payment\n* `completed` - Completed", "explode": true, "style": "form" }, @@ -2794,6 +2796,7 @@ "items": { "type": "string", "enum": [ + "assigned", "canceled", "completed", "draft", @@ -2801,12 +2804,13 @@ "no_payment", "pending", "pending_payment", - "submitted", - "validated" + "signing", + "to_save_payment_method", + "to_sign" ] } }, - "description": "* `draft` - Draft\n* `submitted` - Submitted\n* `pending` - Pending\n* `canceled` - Canceled\n* `validated` - Validated\n* `pending_payment` - Pending payment\n* `failed_payment` - Failed payment\n* `no_payment` - No payment\n* `completed` - Completed", + "description": "* `draft` - Draft\n* `assigned` - Assigned\n* `to_save_payment_method` - To save payment method\n* `to_sign` - To sign\n* `signing` - Signing\n* `pending` - Pending\n* `canceled` - Canceled\n* `pending_payment` - Pending payment\n* `failed_payment` - Failed payment\n* `no_payment` - No payment\n* `completed` - Completed", "explode": true, "style": "form" } @@ -2910,59 +2914,6 @@ } } }, - "/api/v1.0/orders/{id}/abort/": { - "post": { - "operationId": "orders_abort_create", - "description": "Change the state of the order to pending", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "string", - "format": "uuid", - "description": "primary key for the record as UUID" - }, - "required": true - } - ], - "tags": [ - "orders" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OrderRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/OrderRequest" - } - } - }, - "required": true - }, - "security": [ - { - "DelegatedJWTAuthentication": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Order" - } - } - }, - "description": "" - } - } - } - }, "/api/v1.0/orders/{id}/cancel/": { "post": { "operationId": "orders_cancel_create", @@ -3054,10 +3005,10 @@ } } }, - "/api/v1.0/orders/{id}/submit/": { - "patch": { - "operationId": "orders_submit_partial_update", - "description": "Submit a draft order if the conditions are filled", + "/api/v1.0/orders/{id}/payment-method/": { + "post": { + "operationId": "orders_payment_method_create", + "description": "Set the payment method for an order.", "parameters": [ { "in": "path", @@ -3075,14 +3026,10 @@ ], "requestBody": { "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedOrderRequest" - } - }, - "multipart/form-data": { + "credit_card_id": { "schema": { - "$ref": "#/components/schemas/PatchedOrderRequest" + "type": "string", + "format": "uuid" } } } @@ -3097,7 +3044,28 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Order" + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } }, @@ -3203,59 +3171,6 @@ } } }, - "/api/v1.0/orders/{id}/validate/": { - "put": { - "operationId": "orders_validate_update", - "description": "Validate the order", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "string", - "format": "uuid", - "description": "primary key for the record as UUID" - }, - "required": true - } - ], - "tags": [ - "orders" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OrderRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/OrderRequest" - } - } - }, - "required": true - }, - "security": [ - { - "DelegatedJWTAuthentication": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Order" - } - } - }, - "description": "" - } - } - } - }, "/api/v1.0/orders/{id}/withdraw/": { "post": { "operationId": "orders_withdraw_create", @@ -4884,10 +4799,7 @@ }, "student_signed_on": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true, - "title": "Date and time of issuance" + "readOnly": true } }, "required": [ @@ -6068,6 +5980,11 @@ "readOnly": true, "description": "date and time at which a record was created" }, + "credit_card_id": { + "type": "string", + "format": "uuid", + "description": "primary key for the record as UUID" + }, "enrollment": { "allOf": [ { @@ -6127,7 +6044,7 @@ "type": "object", "additionalProperties": {} }, - "description": "For the current order, retrieve its related enrollments.", + "description": "For the current order, retrieve its related enrollments if the order is linked\nto a course.", "readOnly": true }, "total": { @@ -6297,6 +6214,11 @@ "type": "object", "description": "Order model serializer", "properties": { + "credit_card_id": { + "type": "string", + "format": "uuid", + "description": "primary key for the record as UUID" + }, "order_group_id": { "type": "string", "format": "uuid", @@ -6306,31 +6228,28 @@ "type": "string", "format": "uuid", "description": "primary key for the record as UUID" - }, - "has_consent_to_terms": { - "type": "boolean", - "writeOnly": true } }, "required": [ - "has_consent_to_terms", "product_id" ] }, "OrderStateEnum": { "enum": [ "draft", - "submitted", + "assigned", + "to_save_payment_method", + "to_sign", + "signing", "pending", "canceled", - "validated", "pending_payment", "failed_payment", "no_payment", "completed" ], "type": "string", - "description": "* `draft` - Draft\n* `submitted` - Submitted\n* `pending` - Pending\n* `canceled` - Canceled\n* `validated` - Validated\n* `pending_payment` - Pending payment\n* `failed_payment` - Failed payment\n* `no_payment` - No payment\n* `completed` - Completed" + "description": "* `draft` - Draft\n* `assigned` - Assigned\n* `to_save_payment_method` - To save payment method\n* `to_sign` - To sign\n* `signing` - Signing\n* `pending` - Pending\n* `canceled` - Canceled\n* `pending_payment` - Pending payment\n* `failed_payment` - Failed payment\n* `no_payment` - No payment\n* `completed` - Completed" }, "OrderTargetCourseRelation": { "type": "object", @@ -7039,26 +6958,6 @@ } } }, - "PatchedOrderRequest": { - "type": "object", - "description": "Order model serializer", - "properties": { - "order_group_id": { - "type": "string", - "format": "uuid", - "description": "primary key for the record as UUID" - }, - "product_id": { - "type": "string", - "format": "uuid", - "description": "primary key for the record as UUID" - }, - "has_consent_to_terms": { - "type": "boolean", - "writeOnly": true - } - } - }, "PatchedOrganizationAccessRequest": { "type": "object", "description": "Serialize Organization accesses for the API.", diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 2d4533055..d168ca6a9 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -99,6 +99,7 @@ dev = [ "pytest-django==4.8.0", "pytest==8.3.2", "pytest-icdiff==0.9", + "pytest-subtests==0.12.1", "pytest-xdist==3.6.1", "responses==0.25.3", "ruff==0.6.2", diff --git a/src/frontend/admin/public/images/credit-card-brands/maestro.svg b/src/frontend/admin/public/images/credit-card-brands/maestro.svg new file mode 100644 index 000000000..39aae8beb --- /dev/null +++ b/src/frontend/admin/public/images/credit-card-brands/maestro.svg @@ -0,0 +1 @@ + diff --git a/src/frontend/admin/public/images/credit-card-brands/mastercard.svg b/src/frontend/admin/public/images/credit-card-brands/mastercard.svg new file mode 100644 index 000000000..5d5989fb7 --- /dev/null +++ b/src/frontend/admin/public/images/credit-card-brands/mastercard.svg @@ -0,0 +1 @@ + diff --git a/src/frontend/admin/public/images/credit-card-brands/visa.svg b/src/frontend/admin/public/images/credit-card-brands/visa.svg new file mode 100644 index 000000000..73dd0eaa6 --- /dev/null +++ b/src/frontend/admin/public/images/credit-card-brands/visa.svg @@ -0,0 +1,2 @@ + + diff --git a/src/frontend/admin/src/components/presentational/card/SimpleCard.tsx b/src/frontend/admin/src/components/presentational/card/SimpleCard.tsx index 03258a359..627db8201 100644 --- a/src/frontend/admin/src/components/presentational/card/SimpleCard.tsx +++ b/src/frontend/admin/src/components/presentational/card/SimpleCard.tsx @@ -1,20 +1,26 @@ import * as React from "react"; import { PropsWithChildren } from "react"; -import Paper from "@mui/material/Paper"; +import Paper, { PaperProps } from "@mui/material/Paper"; -export function SimpleCard(props: PropsWithChildren) { +export function SimpleCard({ + sx, + children, + ...props +}: PropsWithChildren) { return ( - {props.children} + {children} ); } diff --git a/src/frontend/admin/src/components/presentational/credit-card-brand-logo/CreditCardBrandLogo.spec.tsx b/src/frontend/admin/src/components/presentational/credit-card-brand-logo/CreditCardBrandLogo.spec.tsx new file mode 100644 index 000000000..898711e07 --- /dev/null +++ b/src/frontend/admin/src/components/presentational/credit-card-brand-logo/CreditCardBrandLogo.spec.tsx @@ -0,0 +1,22 @@ +/** + * Test suite for the CreditCardBrandLogo component + */ + +import { render, screen } from "@testing-library/react"; +import CreditCardBrandLogo from "./CreditCardBrandLogo"; + +describe("CreditCardBrandLogo", () => { + it("should render the logo of a known credit card brand", () => { + render(); + + const img = screen.getByAltText("visa"); + expect(img).toBeInstanceOf(HTMLImageElement); + expect(img).toHaveAttribute("src", "/images/credit-card-brands/visa.svg"); + }); + + it("should render a credit card icon if the credit card brand is unknown", () => { + render(); + + screen.getByTestId("CreditCardIcon"); + }); +}); diff --git a/src/frontend/admin/src/components/presentational/credit-card-brand-logo/CreditCardBrandLogo.tsx b/src/frontend/admin/src/components/presentational/credit-card-brand-logo/CreditCardBrandLogo.tsx new file mode 100644 index 000000000..0377a7cb0 --- /dev/null +++ b/src/frontend/admin/src/components/presentational/credit-card-brand-logo/CreditCardBrandLogo.tsx @@ -0,0 +1,27 @@ +import { CreditCard } from "@mui/icons-material"; + +enum SupportedCreditCardBrands { + VISA = "visa", + MASTERCARD = "mastercard", + MAESTRO = "maestro", +} + +function CreditCardBrandLogo({ brand }: { brand: string }) { + const normalizedBrand = brand.toLowerCase(); + + if ( + Object.values(SupportedCreditCardBrands).includes(normalizedBrand) + ) { + return ( + {normalizedBrand} + ); + } + + return ; +} + +export default CreditCardBrandLogo; diff --git a/src/frontend/admin/src/components/presentational/credit-card/CreditCard.spec.e2e.tsx b/src/frontend/admin/src/components/presentational/credit-card/CreditCard.spec.e2e.tsx new file mode 100644 index 000000000..153c05093 --- /dev/null +++ b/src/frontend/admin/src/components/presentational/credit-card/CreditCard.spec.e2e.tsx @@ -0,0 +1,44 @@ +import { expect, test } from "@playwright/experimental-ct-react"; +import CreditCard from "./CreditCard"; +import { CreditCardFactory } from "@/services/factories/credit-cards"; +import { toDigitString } from "@/utils/numbers"; + +test.describe("", () => { + test("should render properly", async ({ mount }) => { + const creditCard = CreditCardFactory({ brand: "visa" }); + const component = await mount(); + + // A label should be displayed + await expect(component.getByText("Payment method")).toBeVisible(); + + // The credit card brand logo should be displayed + const brandSrc = await component.getByRole("img").getAttribute("src"); + expect(brandSrc).toEqual("/images/credit-card-brands/visa.svg"); + + // Last numbers should be displayed + const lastNumbers = component.getByText(creditCard.last_numbers); + await expect(lastNumbers).toBeVisible(); + + // Expiration date should be displayed + const expirationDate = component.getByText( + `${toDigitString(creditCard.expiration_month)} / ${creditCard.expiration_year}`, + { exact: true }, + ); + await expect(expirationDate).toBeVisible(); + }); + + test("should render expired credit card", async ({ mount }) => { + const creditCard = CreditCardFactory({ + brand: "visa", + expiration_year: 2023, + }); + const component = await mount(); + + // Expiration date should be displayed suffixed with "Expired" + const expirationDate = component.getByText( + `${toDigitString(creditCard.expiration_month)} / ${creditCard.expiration_year} (Expired)`, + { exact: true }, + ); + await expect(expirationDate).toBeVisible(); + }); +}); diff --git a/src/frontend/admin/src/components/presentational/credit-card/CreditCard.tsx b/src/frontend/admin/src/components/presentational/credit-card/CreditCard.tsx new file mode 100644 index 000000000..50ed24286 --- /dev/null +++ b/src/frontend/admin/src/components/presentational/credit-card/CreditCard.tsx @@ -0,0 +1,86 @@ +import Typography from "@mui/material/Typography"; +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import { useMemo } from "react"; +import Divider from "@mui/material/Divider"; +import { defineMessages, FormattedMessage } from "react-intl"; +import CreditCardBrandLogo from "@/components/presentational/credit-card-brand-logo/CreditCardBrandLogo"; +import { OrderCreditCard } from "@/services/api/models/Order"; +import { toDigitString } from "@/utils/numbers"; + +type Props = OrderCreditCard; + +const messages = defineMessages({ + paymentMethod: { + id: "components.presentational.card.CreditCard.paymentMethod", + defaultMessage: "Payment method", + description: "Payment method label", + }, + expired: { + id: "components.presentational.card.CreditCard.expired", + defaultMessage: "Expired", + description: "Expired label", + }, +}); + +function CreditCard(props: Props) { + const hasExpired = useMemo(() => { + const currentYear = new Date().getFullYear(); + const currentMonth = new Date().getMonth() + 1; + + return ( + props.expiration_year < currentYear || + (props.expiration_year === currentYear && + props.expiration_month < currentMonth) + ); + }, [props.expiration_month, props.expiration_year]); + + return ( + + + + + + + + •••• •••• •••• {props.last_numbers} + + + + {toDigitString(props.expiration_month)} / {props.expiration_year} + {hasExpired && ( + <> + {" "} + () + + )} + + + + ); +} + +export default CreditCard; diff --git a/src/frontend/admin/src/components/templates/orders/list/OrdersList.tsx b/src/frontend/admin/src/components/templates/orders/list/OrdersList.tsx index 8cb430053..00a968787 100644 --- a/src/frontend/admin/src/components/templates/orders/list/OrdersList.tsx +++ b/src/frontend/admin/src/components/templates/orders/list/OrdersList.tsx @@ -14,6 +14,7 @@ import { PATH_ADMIN } from "@/utils/routes/path"; import { commonTranslations } from "@/translations/common/commonTranslations"; import { OrderFilters } from "@/components/templates/orders/filters/OrderFilters"; import { formatShortDate } from "@/utils/dates"; +import { orderStatesMessages } from "@/components/templates/orders/view/translations"; const messages = defineMessages({ id: { @@ -91,6 +92,7 @@ export function OrdersList(props: Props) { field: "state", headerName: intl.formatMessage(messages.state), flex: 1, + valueGetter: (value) => intl.formatMessage(orderStatesMessages[value]), }, { field: "created_on", diff --git a/src/frontend/admin/src/components/templates/orders/view/OrderView.tsx b/src/frontend/admin/src/components/templates/orders/view/OrderView.tsx index a6045ac66..23d7a6e4d 100644 --- a/src/frontend/admin/src/components/templates/orders/view/OrderView.tsx +++ b/src/frontend/admin/src/components/templates/orders/view/OrderView.tsx @@ -11,10 +11,14 @@ import IconButton from "@mui/material/IconButton"; import Tooltip from "@mui/material/Tooltip"; import Alert from "@mui/material/Alert"; import Typography from "@mui/material/Typography"; -import FormControlLabel from "@mui/material/FormControlLabel"; import { HighlightOff, TaskAlt } from "@mui/icons-material"; import Stack from "@mui/material/Stack"; -import { Order } from "@/services/api/models/Order"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableRow from "@mui/material/TableRow"; +import Chip, { ChipOwnProps } from "@mui/material/Chip"; +import { Order, PaymentStatesEnum } from "@/services/api/models/Order"; import { orderStatesMessages, orderViewMessages, @@ -26,6 +30,7 @@ import { OrderViewInvoiceSection } from "@/components/templates/orders/view/sect import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; import { OrderViewContractSection } from "@/components/templates/orders/view/sections/OrderViewContractSection"; import { OrderViewCertificateSection } from "@/components/templates/orders/view/sections/OrderViewCertificateSection"; +import CreditCard from "@/components/presentational/credit-card/CreditCard"; import { formatShortDate } from "@/utils/dates"; type Props = { @@ -57,6 +62,16 @@ export function OrderView({ order }: Props) { ); }; + const stateColorMapping: Record = { + paid: "success", + refused: "error", + pending: "primary", + }; + + function stateColor(state: PaymentStatesEnum) { + return stateColorMapping[state] || "default"; + } + return ( - - - + + + + + + + {order.credit_card ? ( + + ) : ( + + + + )} + + {order.payment_schedule && ( + + + + {order.payment_schedule?.map((row) => ( + *": { border: 0 } }} + > + + {formatShortDate(row.due_date)} + + + {row.amount} {row.currency} + + + + + + + + ))} + +
+
+ )} +
+
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" %} + + + + + + + {% endwith %} + {% endfor %} +
+ {{ 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 %} +
+
+
+
+ +
+ +
+ Total + {{ product_price|format_currency_with_symbol }} +
+
+
+
+
+
diff --git a/src/mail/mjml/partial/welcome.mjml b/src/mail/mjml/partial/welcome.mjml new file mode 100644 index 000000000..8cf8562f7 --- /dev/null +++ b/src/mail/mjml/partial/welcome.mjml @@ -0,0 +1,20 @@ + + + + + + + + + {% if fullname %} +

+ {% blocktranslate with name=fullname%} + Hello {{ name }}, + {% endblocktranslate %} +

+ {% else %} + {% trans "Hello," %} + {% endif %}
+
+
+
diff --git a/src/tray/templates/services/app/cronjob_process_payment_schedules.yml.j2 b/src/tray/templates/services/app/cronjob_process_payment_schedules.yml.j2 new file mode 100644 index 000000000..8169634eb --- /dev/null +++ b/src/tray/templates/services/app/cronjob_process_payment_schedules.yml.j2 @@ -0,0 +1,81 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + labels: + app: joanie + service: app + version: "{{ joanie_image_tag }}" + deployment_stamp: "{{ deployment_stamp }}" + name: "joanie-process-payment-schedules-{{ deployment_stamp }}" + namespace: "{{ namespace_name }}" +spec: + schedule: "{{ joanie_process_payment_schedules_cronjob_schedule }}" + successfulJobsHistoryLimit: 2 + failedJobsHistoryLimit: 1 + concurrencyPolicy: Forbid + suspend: {{ suspend_cronjob | default(false) }} + jobTemplate: + spec: + template: + metadata: + name: "joanie-process-payment-schedules-{{ deployment_stamp }}" + labels: + app: joanie + service: app + version: "{{ joanie_image_tag }}" + deployment_stamp: "{{ deployment_stamp }}" + spec: +{% set image_pull_secret_name = joanie_image_pull_secret_name | default(none) or default_image_pull_secret_name %} +{% if image_pull_secret_name is not none %} + imagePullSecrets: + - name: "{{ image_pull_secret_name }}" +{% endif %} + containers: + - name: "joanie-process-payment-schedules" + image: "{{ joanie_image_name }}:{{ joanie_image_tag }}" + imagePullPolicy: Always + command: + - "/bin/bash" + - "-c" + - python manage.py process_payment_schedules + env: + - name: DB_HOST + value: "joanie-{{ joanie_database_host }}-{{ deployment_stamp }}" + - name: DB_NAME + value: "{{ joanie_database_name }}" + - name: DB_PORT + value: "{{ joanie_database_port }}" + - name: DJANGO_ALLOWED_HOSTS + value: "{{ joanie_host | blue_green_hosts }},{{ joanie_admin_host | blue_green_hosts }}" + - name: DJANGO_CSRF_TRUSTED_ORIGINS + value: "{{ joanie_host | blue_green_hosts | split(',') | map('regex_replace', '^(.*)$', 'https://\\1') | join(',') }},{{ joanie_admin_host | blue_green_hosts | split(',') | map('regex_replace', '^(.*)$', 'https://\\1') | join(',') }}" + - name: DJANGO_CONFIGURATION + value: "{{ joanie_django_configuration }}" + - name: DJANGO_CORS_ALLOWED_ORIGINS + value: "{{ richie_host | blue_green_hosts | split(',') | map('regex_replace', '^(.*)$', 'https://\\1') | join(',') }},{{ joanie_admin_host | blue_green_hosts | split(',') | map('regex_replace', '^(.*)$', 'https://\\1') | join(',') }}" + - name: DJANGO_CSRF_COOKIE_DOMAIN + value: ".{{ joanie_host }}" + - name: DJANGO_SETTINGS_MODULE + value: joanie.configs.settings + - name: JOANIE_BACKOFFICE_BASE_URL + value: "https://{{ joanie_admin_host }}" + - name: DJANGO_CELERY_DEFAULT_QUEUE + value: "default-queue-{{ deployment_stamp }}" + envFrom: + - secretRef: + name: "{{ joanie_secret_name }}" + - configMapRef: + name: "joanie-app-dotenv-{{ deployment_stamp }}" + resources: {{ joanie_process_payment_schedules_cronjob_resources }} + volumeMounts: + - name: joanie-configmap + mountPath: /app/joanie/configs + restartPolicy: Never + securityContext: + runAsUser: {{ container_uid }} + runAsGroup: {{ container_gid }} + volumes: + - name: joanie-configmap + configMap: + defaultMode: 420 + name: joanie-app-{{ deployment_stamp }} diff --git a/src/tray/templates/services/app/cronjob_send_mail_upcoming_debit.yml.j2 b/src/tray/templates/services/app/cronjob_send_mail_upcoming_debit.yml.j2 new file mode 100644 index 000000000..e435c669d --- /dev/null +++ b/src/tray/templates/services/app/cronjob_send_mail_upcoming_debit.yml.j2 @@ -0,0 +1,81 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + labels: + app: joanie + service: app + version: "{{ joanie_image_tag }}" + deployment_stamp: "{{ deployment_stamp }}" + name: "joanie-send-mail-upcoming-debit-{{ deployment_stamp }}" + namespace: "{{ namespace_name }}" +spec: + schedule: "{{ joanie_send_mail_upcoming_debit_cronjob_schedule }}" + successfulJobsHistoryLimit: 2 + failedJobsHistoryLimit: 1 + concurrencyPolicy: Forbid + suspend: {{ suspend_cronjob | default(false) }} + jobTemplate: + spec: + template: + metadata: + name: "joanie-send-mail-upcoming-debit-{{ deployment_stamp }}" + labels: + app: joanie + service: app + version: "{{ joanie_image_tag }}" + deployment_stamp: "{{ deployment_stamp }}" + spec: +{% set image_pull_secret_name = joanie_image_pull_secret_name | default(none) or default_image_pull_secret_name %} +{% if image_pull_secret_name is not none %} + imagePullSecrets: + - name: "{{ image_pull_secret_name }}" +{% endif %} + containers: + - name: "joanie-send-mail-upcoming-debit" + image: "{{ joanie_image_name }}:{{ joanie_image_tag }}" + imagePullPolicy: Always + command: + - "/bin/bash" + - "-c" + - python manage.py send_mail_upcoming_debit + env: + - name: DB_HOST + value: "joanie-{{ joanie_database_host }}-{{ deployment_stamp }}" + - name: DB_NAME + value: "{{ joanie_database_name }}" + - name: DB_PORT + value: "{{ joanie_database_port }}" + - name: DJANGO_ALLOWED_HOSTS + value: "{{ joanie_host | blue_green_hosts }},{{ joanie_admin_host | blue_green_hosts }}" + - name: DJANGO_CSRF_TRUSTED_ORIGINS + value: "{{ joanie_host | blue_green_hosts | split(',') | map('regex_replace', '^(.*)$', 'https://\\1') | join(',') }},{{ joanie_admin_host | blue_green_hosts | split(',') | map('regex_replace', '^(.*)$', 'https://\\1') | join(',') }}" + - name: DJANGO_CONFIGURATION + value: "{{ joanie_django_configuration }}" + - name: DJANGO_CORS_ALLOWED_ORIGINS + value: "{{ richie_host | blue_green_hosts | split(',') | map('regex_replace', '^(.*)$', 'https://\\1') | join(',') }},{{ joanie_admin_host | blue_green_hosts | split(',') | map('regex_replace', '^(.*)$', 'https://\\1') | join(',') }}" + - name: DJANGO_CSRF_COOKIE_DOMAIN + value: ".{{ joanie_host }}" + - name: DJANGO_SETTINGS_MODULE + value: joanie.configs.settings + - name: JOANIE_BACKOFFICE_BASE_URL + value: "https://{{ joanie_admin_host }}" + - name: DJANGO_CELERY_DEFAULT_QUEUE + value: "default-queue-{{ deployment_stamp }}" + envFrom: + - secretRef: + name: "{{ joanie_secret_name }}" + - configMapRef: + name: "joanie-app-dotenv-{{ deployment_stamp }}" + resources: {{ joanie_send_mail_upcoming_debit_cronjob_resources }} + volumeMounts: + - name: joanie-configmap + mountPath: /app/joanie/configs + restartPolicy: Never + securityContext: + runAsUser: {{ container_uid }} + runAsGroup: {{ container_gid }} + volumes: + - name: joanie-configmap + configMap: + defaultMode: 420 + name: joanie-app-{{ deployment_stamp }} diff --git a/src/tray/templates/services/app/job_db_migrate.yml.j2 b/src/tray/templates/services/app/job_db_migrate.yml.j2 index c276200a2..1feef3b4b 100644 --- a/src/tray/templates/services/app/job_db_migrate.yml.j2 +++ b/src/tray/templates/services/app/job_db_migrate.yml.j2 @@ -45,9 +45,19 @@ spec: envFrom: - secretRef: name: "{{ joanie_secret_name }}" + - configMapRef: + name: "joanie-app-dotenv-{{ deployment_stamp }}" command: ["python", "manage.py", "migrate"] resources: {{ joanie_app_job_db_migrate_resources }} + volumeMounts: + - name: joanie-configmap + mountPath: /app/joanie/configs restartPolicy: Never securityContext: runAsUser: {{ container_uid }} runAsGroup: {{ container_gid }} + volumes: + - name: joanie-configmap + configMap: + defaultMode: 420 + name: joanie-app-{{ deployment_stamp }} diff --git a/src/tray/vars/all/main.yml b/src/tray/vars/all/main.yml index 7633624f9..7e1af98f8 100644 --- a/src/tray/vars/all/main.yml +++ b/src/tray/vars/all/main.yml @@ -54,7 +54,7 @@ joanie_activate_http_basic_auth: false # -- joanie celery joanie_celery_replicas: 1 -joanie_celery_command: +joanie_celery_command: - celery - -A - joanie.celery_app @@ -82,6 +82,10 @@ joanie_celery_readynessprobe: periodSeconds: 10 timeoutSeconds: 5 +# Joanie cronjobs +joanie_process_payment_schedules_cronjob_schedule: "0 3 * * *" +joanie_send_mail_upcoming_debit_cronjob_schedule: "0 3 * * *" + # -- resources {% set app_resources = { "requests": { @@ -92,6 +96,8 @@ joanie_celery_readynessprobe: joanie_app_resources: "{{ app_resources }}" joanie_app_job_db_migrate_resources: "{{ app_resources }}" +joanie_process_payment_schedules_cronjob_resources: "{{ app_resources }}" +joanie_send_mail_upcoming_debit_cronjob_resources: "{{ app_resources }}" joanie_nginx_resources: requests: