From 0bd8a1ea18704ee033bce85f7164b94ff9d02575 Mon Sep 17 00:00:00 2001 From: Jonathan Reveille Date: Tue, 24 Sep 2024 18:54:36 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20admin=20api=20refund=20and?= =?UTF-8?q?=20cancel=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added new endpoints for the admin API to allow to cancel and refund an order. --- src/backend/joanie/core/api/admin/__init__.py | 44 +++++ .../joanie/core/tasks/payment_schedule.py | 18 ++ .../tests/core/test_api_admin_orders.py | 124 ++++++++++++ .../joanie/tests/swagger/admin-swagger.json | 186 +++++++++++++++++- src/backend/joanie/tests/swagger/swagger.json | 21 +- 5 files changed, 380 insertions(+), 13 deletions(-) diff --git a/src/backend/joanie/core/api/admin/__init__.py b/src/backend/joanie/core/api/admin/__init__.py index 846b78ac6..e59d9bef5 100755 --- a/src/backend/joanie/core/api/admin/__init__.py +++ b/src/backend/joanie/core/api/admin/__init__.py @@ -29,10 +29,14 @@ generate_certificates_task, update_organization_signatories_contracts_task, ) +from joanie.core.tasks.payment_schedule import refund_installments_task from joanie.core.utils.course_product_relation import ( get_generated_certificates, get_orders, ) +from joanie.core.utils.payment_schedule import ( + has_installment_paid, +) from .enrollment import EnrollmentViewSet @@ -636,6 +640,46 @@ def generate_certificate(self, request, pk=None): # pylint:disable=unused-argum return Response(certificate, status=HTTPStatus.CREATED) + @action(methods=["POST"], detail=True) + def cancel(self, request, pk=None): # pylint:disable=unused-argument + """ + Cancel an order. + """ + order = self.get_object() + + if order.state == enums.ORDER_STATE_COMPLETED: + return Response( + "Cannot cancel a completed order.", + status=HTTPStatus.UNPROCESSABLE_ENTITY, + ) + + order.flow.cancel() + return Response(status=HTTPStatus.OK) + + @action(methods=["POST"], detail=True) + def refund(self, request, pk=None): # pylint:disable=unused-argument + """ + Refund an order only if the order is in state 'cancel' and at least 1 installment + has been paid in the payment schedule. + """ + order = self.get_object() + + if order.state != enums.ORDER_STATE_CANCELED: + return Response( + "Cannot refund an order not canceled.", + status=HTTPStatus.UNPROCESSABLE_ENTITY, + ) + + if not has_installment_paid(order): + return Response( + "Cannot refund an order without paid installments in payment schedule.", + status=HTTPStatus.BAD_REQUEST, + ) + + refund_installments_task.delay(order_id=order.id) + + return Response(status=HTTPStatus.ACCEPTED) + class OrganizationAddressViewSet( mixins.CreateModelMixin, diff --git a/src/backend/joanie/core/tasks/payment_schedule.py b/src/backend/joanie/core/tasks/payment_schedule.py index b6f2b5e66..82919328c 100644 --- a/src/backend/joanie/core/tasks/payment_schedule.py +++ b/src/backend/joanie/core/tasks/payment_schedule.py @@ -2,10 +2,13 @@ from logging import getLogger +from django.core.exceptions import ValidationError + from joanie.celery_app import app from joanie.core.models import Order from joanie.core.utils.payment_schedule import ( is_installment_to_debit, + refund_paid_installments, send_mail_reminder_for_installment_debit, ) from joanie.payment import get_payment_backend @@ -50,3 +53,18 @@ def send_mail_reminder_installment_debit_task(order_id, installment_id): None, ) send_mail_reminder_for_installment_debit(order, installment) + + +@app.task +def refund_installments_task(order_id): + """ + Task to refund the installments paid in a payment schedule of an order. + """ + try: + order = Order.objects.get(id=order_id) + except Order.DoesNotExist as error: + raise ValidationError("The given `order_id` does not exist.") from error + + logger.info("Starting Celery task, refunding order id: %s...", order_id) + refund_paid_installments(order) + logger.info("Starting Celery task, done refunding order id: %s", order_id) 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 03f3fa535..7009019c7 100644 --- a/src/backend/joanie/tests/core/test_api_admin_orders.py +++ b/src/backend/joanie/tests/core/test_api_admin_orders.py @@ -1525,3 +1525,127 @@ def test_api_admin_orders_generate_certificate_when_order_is_not_ready_for_gradi self.assertDictEqual( response.json(), {"details": "This order is not ready for gradation."} ) + + def test_api_admin_orders_cancel_request_without_authentication(self): + """ + Anonymous users should not be able to request cancel order endpoint. + """ + order = factories.OrderGeneratorFactory(state=enums.ORDER_STATE_PENDING_PAYMENT) + + response = self.client.post(f"/api/v1.0/admin/orders/{order.id}/cancel/") + + self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED) + + def test_api_admin_orders_cancel_request_with_lambda_user(self): + """ + Lambda users should not be able to request cancel order endpoint. + """ + admin = factories.UserFactory(is_staff=False, is_superuser=False) + self.client.login(username=admin.username, password="password") + order = factories.OrderGeneratorFactory(state=enums.ORDER_STATE_PENDING_PAYMENT) + + response = self.client.post(f"/api/v1.0/admin/orders/{order.id}/cancel/") + + self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) + + def test_api_admin_orders_cancel_with_an_invalid_order_id(self): + """ + Authenticated admin user should not be able to cancel an order by passing + an invalid order id. + """ + admin = factories.UserFactory(is_staff=True, is_superuser=True) + self.client.login(username=admin.username, password="password") + + response = self.client.post("/api/v1.0/admin/orders/invalid_id/cancel/") + + self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND) + + def test_api_admin_orders_cancel_with_get_method_is_not_allowed(self): + """ + Authenticated admin users should not be able to use the get method to request to cancel + an order endpoint. + """ + admin = factories.UserFactory(is_staff=True, is_superuser=True) + self.client.login(username=admin.username, password="password") + order = factories.OrderGeneratorFactory(state=enums.ORDER_STATE_PENDING_PAYMENT) + + response = self.client.get(f"/api/v1.0/admin/orders/{order.id}/cancel/") + + self.assertEqual(response.status_code, HTTPStatus.METHOD_NOT_ALLOWED) + self.assertEqual(order.state, enums.ORDER_STATE_PENDING_PAYMENT) + + def test_api_admin_orders_cancel_with_put_method_is_not_allowed(self): + """ + Authenticated admin users should not be able to use the put method to update the request to + to cancel an order endpoint. + """ + admin = factories.UserFactory(is_staff=True, is_superuser=True) + self.client.login(username=admin.username, password="password") + order = factories.OrderGeneratorFactory(state=enums.ORDER_STATE_PENDING_PAYMENT) + + response = self.client.put(f"/api/v1.0/admin/orders/{order.id}/cancel/") + + self.assertEqual(response.status_code, HTTPStatus.METHOD_NOT_ALLOWED) + self.assertEqual(order.state, enums.ORDER_STATE_PENDING_PAYMENT) + + def test_api_admin_orders_cancel_with_patch_method_is_not_allowed(self): + """ + Authenticated admin users should not be able to use the patch method to update a request + to cancel an order endpoint. + """ + admin = factories.UserFactory(is_staff=True, is_superuser=True) + self.client.login(username=admin.username, password="password") + order = factories.OrderGeneratorFactory(state=enums.ORDER_STATE_PENDING_PAYMENT) + + response = self.client.patch(f"/api/v1.0/admin/orders/{order.id}/cancel/") + + self.assertEqual(response.status_code, HTTPStatus.METHOD_NOT_ALLOWED) + self.assertEqual(order.state, enums.ORDER_STATE_PENDING_PAYMENT) + + def test_api_admin_orders_cancel_with_delete_method_is_not_allowed(self): + """ + Authenticated admin users should not be able to use the delete method to delete an order on + the cancel an order endpoint. + """ + admin = factories.UserFactory(is_staff=True, is_superuser=True) + self.client.login(username=admin.username, password="password") + order = factories.OrderGeneratorFactory(state=enums.ORDER_STATE_PENDING_PAYMENT) + + response = self.client.delete(f"/api/v1.0/admin/orders/{order.id}/cancel/") + + self.assertEqual(response.status_code, HTTPStatus.METHOD_NOT_ALLOWED) + self.assertEqual(order.state, enums.ORDER_STATE_PENDING_PAYMENT) + + def test_api_admin_orders_cancel_with_post_method_success(self): + """ + Authenticated admin users should be able to use the post method to request to cancel an + order endpoint. The order's state should be 'canceled' after calling the endpoint. + """ + admin = factories.UserFactory(is_staff=True, is_superuser=True) + self.client.login(username=admin.username, password="password") + order = factories.OrderGeneratorFactory(state=enums.ORDER_STATE_PENDING_PAYMENT) + + response = self.client.post(f"/api/v1.0/admin/orders/{order.id}/cancel/") + + order.refresh_from_db() + + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertEqual(order.state, enums.ORDER_STATE_CANCELED) + + def test_api_admin_orders_cancel_an_order_that_is_completed_should_not_cancel_the_order( + self, + ): + """ + Authenticated admin users should not be able to cancel an order that is in state + `completed`. + """ + admin = factories.UserFactory(is_staff=True, is_superuser=True) + self.client.login(username=admin.username, password="password") + order = factories.OrderGeneratorFactory(state=enums.ORDER_STATE_COMPLETED) + + response = self.client.post(f"/api/v1.0/admin/orders/{order.id}/cancel/") + + order.refresh_from_db() + + self.assertEqual(response.status_code, HTTPStatus.UNPROCESSABLE_ENTITY) + self.assertEqual(order.state, enums.ORDER_STATE_COMPLETED) diff --git a/src/backend/joanie/tests/swagger/admin-swagger.json b/src/backend/joanie/tests/swagger/admin-swagger.json index de2fe6326..914443d3e 100644 --- a/src/backend/joanie/tests/swagger/admin-swagger.json +++ b/src/backend/joanie/tests/swagger/admin-swagger.json @@ -2891,12 +2891,13 @@ "no_payment", "pending", "pending_payment", + "refund", "signing", "to_save_payment_method", "to_sign" ] }, - "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" + "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\n* `refund` - Refund" } ], "tags": [ @@ -2988,6 +2989,59 @@ } } }, + "/api/v1.0/admin/orders/{id}/cancel/": { + "post": { + "operationId": "orders_cancel_create", + "description": "Cancel an 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/AdminOrderRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/AdminOrderRequest" + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminOrder" + } + } + }, + "description": "" + } + } + } + }, "/api/v1.0/admin/orders/{id}/generate_certificate/": { "post": { "operationId": "orders_generate_certificate_create", @@ -3056,6 +3110,59 @@ } } }, + "/api/v1.0/admin/orders/{id}/refund/": { + "post": { + "operationId": "orders_refund_create", + "description": "Refund an order only if the order is in state 'cancel' and at least 1 installment\nhas been paid in the payment schedule.", + "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/AdminOrderRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/AdminOrderRequest" + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminOrder" + } + } + }, + "description": "" + } + } + } + }, "/api/v1.0/admin/organizations/": { "get": { "operationId": "organizations_list", @@ -5452,6 +5559,23 @@ "updated_on" ] }, + "AdminInvoiceRequest": { + "type": "object", + "description": "Read only serializer for Invoice model.", + "properties": { + "total": { + "type": "number", + "format": "double", + "maximum": 10000000, + "minimum": -10000000, + "exclusiveMaximum": true, + "exclusiveMinimum": true + } + }, + "required": [ + "total" + ] + }, "AdminOrder": { "type": "object", "description": "Read only Serializer for Order model.", @@ -5874,14 +5998,65 @@ "state" ] }, + "AdminOrderPaymentRequest": { + "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 + }, + "due_date": { + "type": "string", + "format": "date" + }, + "state": { + "$ref": "#/components/schemas/AdminOrderPaymentStateEnum" + } + }, + "required": [ + "amount", + "due_date", + "id", + "state" + ] + }, "AdminOrderPaymentStateEnum": { "enum": [ "pending", "paid", - "refused" + "refused", + "refunded" ], "type": "string", - "description": "* `pending` - Pending\n* `paid` - Paid\n* `refused` - Refused" + "description": "* `pending` - Pending\n* `paid` - Paid\n* `refused` - Refused\n* `refunded` - Refunded" + }, + "AdminOrderRequest": { + "type": "object", + "description": "Read only Serializer for Order model.", + "properties": { + "total": { + "type": "number", + "format": "double", + "maximum": 10000000, + "minimum": 0.0, + "exclusiveMaximum": true + }, + "main_invoice": { + "$ref": "#/components/schemas/AdminInvoiceRequest" + } + }, + "required": [ + "main_invoice", + "total" + ] }, "AdminOrganization": { "type": "object", @@ -7017,10 +7192,11 @@ "pending_payment", "failed_payment", "no_payment", - "completed" + "completed", + "refund" ], "type": "string", - "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" + "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\n* `refund` - Refund" }, "OrganizationAccessRoleChoiceEnum": { "enum": [ diff --git a/src/backend/joanie/tests/swagger/swagger.json b/src/backend/joanie/tests/swagger/swagger.json index 57ee7b8ce..ee2ccba32 100644 --- a/src/backend/joanie/tests/swagger/swagger.json +++ b/src/backend/joanie/tests/swagger/swagger.json @@ -2778,13 +2778,14 @@ "no_payment", "pending", "pending_payment", + "refund", "signing", "to_save_payment_method", "to_sign" ] } }, - "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", + "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\n* `refund` - Refund", "explode": true, "style": "form" }, @@ -2804,13 +2805,14 @@ "no_payment", "pending", "pending_payment", + "refund", "signing", "to_save_payment_method", "to_sign" ] } }, - "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", + "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\n* `refund` - Refund", "explode": true, "style": "form" } @@ -4514,10 +4516,11 @@ "enum": [ "notification", "payment_succeeded", - "payment_failed" + "payment_failed", + "payment_refunded" ], "type": "string", - "description": "* `notification` - Notification\n* `payment_succeeded` - Payment succeeded\n* `payment_failed` - Payment failed" + "description": "* `notification` - Notification\n* `payment_succeeded` - Payment succeeded\n* `payment_failed` - Payment failed\n* `payment_refunded` - Payment refunded" }, "Address": { "type": "object", @@ -6208,10 +6211,11 @@ "enum": [ "pending", "paid", - "refused" + "refused", + "refunded" ], "type": "string", - "description": "* `pending` - Pending\n* `paid` - Paid\n* `refused` - Refused" + "description": "* `pending` - Pending\n* `paid` - Paid\n* `refused` - Refused\n* `refunded` - Refunded" }, "OrderRequest": { "type": "object", @@ -6249,10 +6253,11 @@ "pending_payment", "failed_payment", "no_payment", - "completed" + "completed", + "refund" ], "type": "string", - "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" + "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\n* `refund` - Refund" }, "OrderTargetCourseRelation": { "type": "object",