diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a25c956a..b89b50c92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to ### Added +- Added `refund` state to order flow and added refund action on payment backends + and cancel/refund endpoint for admin users - 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 @@ -34,7 +36,7 @@ and this project adheres to payments of installments that are in the past - Deprecated field `has_consent_to_terms` for `Order` model - Move signature fields before appendices in contract definition template -- Update `handle_notification` signature backend to confirm signature +- Update `handle_notification` signature backend to confirm signature ### Fixed diff --git a/docs/explanation/payment-backend.md b/docs/explanation/payment-backend.md index dd8da0451..9f4c82f95 100644 --- a/docs/explanation/payment-backend.md +++ b/docs/explanation/payment-backend.md @@ -21,7 +21,7 @@ or is refunded : - **`_do_on_payment_success(order, payment)`** - **`_do_on_payment_failure(order)`** -- **`_do_on_refund(amount, invoice, refund_reference)`** +- **`_do_on_refund(amount, invoice, refund_reference, installment_id, is_transaction_canceled)`** On the other hand, your payment backend has to implement 5 methods : @@ -60,18 +60,18 @@ url. To simplify this task, we have integrated a [localtunnel](https://theboroer First you have to start joanie application then open a localtunnel. To ease this step, there is a command `tunnel` available in the Makefile. So `make tunnel` will run joanie application then open a localtunnel. The process will stay in foreground and will - print all requests catched by your localtunnel. + print all requests catched by your localtunnel. ```bash > make tunnel [+] Building 0.0s (0/0) docker:desktop-linux [+] Running 3/3 - ✔ Container joanie-postgresql-1 Running 0.0s - ✔ Container joanie-app-1 Running 0.0s - ✔ Container joanie-nginx-1 Started 0.1s + ✔ Container joanie-postgresql-1 Running 0.0s + ✔ Container joanie-app-1 Running 0.0s + ✔ Container joanie-nginx-1 Started 0.1s ... - + npx localtunnel -s dev-****-joanie -h https://localtunnel.me --port 8071 --print-requests your url is: https://dev-****-joanie.loca.lt # Copy this url ``` diff --git a/src/backend/joanie/core/api/admin/__init__.py b/src/backend/joanie/core/api/admin/__init__.py index 846b78ac6..eb462f908 100755 --- a/src/backend/joanie/core/api/admin/__init__.py +++ b/src/backend/joanie/core/api/admin/__init__.py @@ -33,6 +33,11 @@ get_generated_certificates, get_orders, ) +from joanie.core.utils.payment_schedule import ( + get_refundable_transactions, + has_installment_paid, +) +from joanie.payment import get_payment_backend from .enrollment import EnrollmentViewSet @@ -636,6 +641,50 @@ 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() + + 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, + ) + + # Mark the order to 'refund', only admin users will be able to do it. + order.flow.refund() + + payment_backend = get_payment_backend() + to_refund_references = get_refundable_transactions(order) + for transaction_reference, amount in to_refund_references.items(): + payment_backend.cancel_or_refund( + amount=amount, + transaction_reference=transaction_reference, + ) + + return Response(status=HTTPStatus.ACCEPTED) + class OrganizationAddressViewSet( mixins.CreateModelMixin, diff --git a/src/backend/joanie/core/enums.py b/src/backend/joanie/core/enums.py index 2f81c02f2..eeca849af 100644 --- a/src/backend/joanie/core/enums.py +++ b/src/backend/joanie/core/enums.py @@ -70,6 +70,7 @@ 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 +ORDER_STATE_REFUND = "refund" # order payments were refund ORDER_STATE_CHOICES = ( (ORDER_STATE_DRAFT, _("Draft")), # default @@ -95,6 +96,7 @@ ORDER_STATE_COMPLETED, pgettext_lazy("As in: the order is completed.", "Completed"), ), + (ORDER_STATE_REFUND, pgettext_lazy("As in: the order is refund", "Refund")), ) ORDER_STATE_ALLOW_ENROLLMENT = ( ORDER_STATE_COMPLETED, @@ -176,19 +178,23 @@ ACTIVITY_LOG_TYPE_NOTIFICATION = "notification" ACTIVITY_LOG_TYPE_PAYMENT_SUCCEEDED = "payment_succeeded" ACTIVITY_LOG_TYPE_PAYMENT_FAILED = "payment_failed" +ACTIVITY_LOG_TYPE_PAYMENT_REFUNDED = "payment_refunded" ACTIVITY_LOG_TYPE_CHOICES = ( (ACTIVITY_LOG_TYPE_NOTIFICATION, _("Notification")), (ACTIVITY_LOG_TYPE_PAYMENT_SUCCEEDED, _("Payment succeeded")), (ACTIVITY_LOG_TYPE_PAYMENT_FAILED, _("Payment failed")), + (ACTIVITY_LOG_TYPE_PAYMENT_REFUNDED, _("Payment refunded")), ) PAYMENT_STATE_PENDING = "pending" PAYMENT_STATE_PAID = "paid" PAYMENT_STATE_REFUSED = "refused" +PAYMENT_STATE_REFUNDED = "refunded" PAYMENT_STATE_CHOICES = ( (PAYMENT_STATE_PENDING, _("Pending")), (PAYMENT_STATE_PAID, _("Paid")), (PAYMENT_STATE_REFUSED, _("Refused")), + (PAYMENT_STATE_REFUNDED, _("Refunded")), ) diff --git a/src/backend/joanie/core/factories.py b/src/backend/joanie/core/factories.py index 00593b4b0..64492788a 100644 --- a/src/backend/joanie/core/factories.py +++ b/src/backend/joanie/core/factories.py @@ -29,7 +29,7 @@ ProductTargetCourseRelation, ) from joanie.core.serializers import AddressSerializer -from joanie.core.utils import contract_definition, file_checksum +from joanie.core.utils import contract_definition, file_checksum, payment_schedule from joanie.core.utils.payment_schedule import ( convert_amount_str_to_money_object, convert_date_str_to_date_object, @@ -775,6 +775,7 @@ def contract(self, create, extracted, **kwargs): enums.ORDER_STATE_FAILED_PAYMENT, enums.ORDER_STATE_COMPLETED, enums.ORDER_STATE_CANCELED, + enums.ORDER_STATE_REFUND, ]: if not self.product.contract_definition: self.product.contract_definition = ContractDefinitionFactory() @@ -838,6 +839,7 @@ def credit_card(self): enums.ORDER_STATE_FAILED_PAYMENT, enums.ORDER_STATE_COMPLETED, enums.ORDER_STATE_CANCELED, + enums.ORDER_STATE_REFUND, ]: from joanie.payment.factories import ( # pylint: disable=import-outside-toplevel, cyclic-import CreditCardFactory, @@ -929,6 +931,7 @@ def billing_address(self, create, extracted, **kwargs): enums.ORDER_STATE_NO_PAYMENT, enums.ORDER_STATE_FAILED_PAYMENT, enums.ORDER_STATE_COMPLETED, + enums.ORDER_STATE_REFUND, ] and not self.is_free ): @@ -936,7 +939,10 @@ def billing_address(self, create, extracted, **kwargs): TransactionFactory, ) - if target_state == enums.ORDER_STATE_PENDING_PAYMENT: + if target_state in [ + enums.ORDER_STATE_PENDING_PAYMENT, + enums.ORDER_STATE_REFUND, + ]: self.payment_schedule[0]["state"] = enums.PAYMENT_STATE_PAID # Create related transactions when an installment is paid TransactionFactory( @@ -979,6 +985,14 @@ def billing_address(self, create, extracted, **kwargs): if target_state == enums.ORDER_STATE_CANCELED: self.flow.cancel() + if ( + self.state == enums.ORDER_STATE_PENDING_PAYMENT + and target_state == enums.ORDER_STATE_REFUND + and payment_schedule.has_installment_paid(self) + ): + self.flow.cancel() + self.flow.refund() + @factory.post_generation # pylint: disable=method-hidden def payment_schedule(self, create, extracted, **kwargs): @@ -1257,6 +1271,7 @@ def context(self): if self.type in [ enums.ACTIVITY_LOG_TYPE_PAYMENT_SUCCEEDED, enums.ACTIVITY_LOG_TYPE_PAYMENT_FAILED, + enums.ACTIVITY_LOG_TYPE_PAYMENT_REFUNDED, ]: return {"order_id": str(factory.Faker("uuid4"))} return {} diff --git a/src/backend/joanie/core/flows/order.py b/src/backend/joanie/core/flows/order.py index 9c8b6f64e..f404ee643 100644 --- a/src/backend/joanie/core/flows/order.py +++ b/src/backend/joanie/core/flows/order.py @@ -11,6 +11,7 @@ from joanie.core import enums from joanie.core.utils.payment_schedule import ( + has_installment_paid, has_installments_to_debit, is_installment_to_debit, ) @@ -244,6 +245,23 @@ def failed_payment(self): Mark order instance as "failed_payment". """ + def _can_be_state_refund_payment(self): + """ + An order state can be set to refund if the order's state is 'canceled' exclusively. + To refund, there should be at least one installment paid in the payment schedule. + """ + return has_installment_paid(self.instance) + + @state.transition( + source=enums.ORDER_STATE_CANCELED, + target=enums.ORDER_STATE_REFUND, + conditions=[_can_be_state_refund_payment], + ) + def refund(self): + """ + Mark an order has refund. + """ + def update(self): """ Update the order state. @@ -258,6 +276,7 @@ def update(self): self.pending_payment, self.no_payment, self.failed_payment, + self.refund, ]: with suppress(fsm.TransitionNotAllowed): logger.debug( diff --git a/src/backend/joanie/core/models/activity_logs.py b/src/backend/joanie/core/models/activity_logs.py index ee516d756..0596418de 100644 --- a/src/backend/joanie/core/models/activity_logs.py +++ b/src/backend/joanie/core/models/activity_logs.py @@ -33,6 +33,7 @@ def validate(self, value, model_instance): elif activity_log_type in [ enums.ACTIVITY_LOG_TYPE_PAYMENT_SUCCEEDED, enums.ACTIVITY_LOG_TYPE_PAYMENT_FAILED, + enums.ACTIVITY_LOG_TYPE_PAYMENT_REFUNDED, ]: self.validate_payment_type(value) else: @@ -109,6 +110,18 @@ def create_payment_failed_activity_log(cls, order): type=enums.ACTIVITY_LOG_TYPE_PAYMENT_FAILED, ) + @classmethod + def create_payment_refunded_activity_log(cls, order): + """ + Create a payment refunded activity log + """ + return cls.objects.create( + user=order.owner, + level=enums.ACTIVITY_LOG_LEVEL_SUCCESS, + context={"order_id": str(order.id)}, + type=enums.ACTIVITY_LOG_TYPE_PAYMENT_REFUNDED, + ) + def __str__(self): return f"{self.user}: {self.level} {self.type}" diff --git a/src/backend/joanie/core/models/products.py b/src/backend/joanie/core/models/products.py index b69b96cd4..128b292e4 100644 --- a/src/backend/joanie/core/models/products.py +++ b/src/backend/joanie/core/models/products.py @@ -1123,7 +1123,6 @@ def _set_installment_state(self, installment_id, state): self.save(update_fields=["payment_schedule"]) self.flow.update() return - raise ValueError(f"Installment with id {installment_id} not found") def set_installment_paid(self, installment_id): @@ -1140,6 +1139,21 @@ def set_installment_refused(self, installment_id): ActivityLog.create_payment_failed_activity_log(self) self._set_installment_state(installment_id, enums.PAYMENT_STATE_REFUSED) + def set_installment_refunded(self, installment_id): + """ + Set the state of an installment to `refunded` in the payment schedule. + """ + ActivityLog.create_payment_refunded_activity_log(self) + for installment in self.payment_schedule: + if ( + installment["id"] == installment_id + and installment["state"] == enums.PAYMENT_STATE_PAID + ): + installment["state"] = enums.PAYMENT_STATE_REFUNDED + self.save(update_fields=["payment_schedule"]) + return + raise ValueError(f"Installment with id {installment_id} cannot be refund") + def get_first_installment_refused(self): """ Retrieve the first installment that is refused in payment schedule of an order. diff --git a/src/backend/joanie/core/utils/payment_schedule.py b/src/backend/joanie/core/utils/payment_schedule.py index 74cb7247b..90feada29 100644 --- a/src/backend/joanie/core/utils/payment_schedule.py +++ b/src/backend/joanie/core/utils/payment_schedule.py @@ -5,6 +5,7 @@ import logging import uuid from datetime import date, timedelta +from decimal import Decimal from django.conf import settings from django.utils import timezone @@ -19,6 +20,7 @@ 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 +from joanie.payment.models import Invoice, Transaction logger = logging.getLogger(__name__) @@ -223,3 +225,87 @@ def send_mail_reminder_for_installment_debit(order, installment): template_name="installment_reminder", to_user_email=order.owner.email, ) + + +def has_installment_paid(order): + """ + Check if at least 1 installment is paid in the payment schedule. + """ + return not order.is_free and any( + installment.get("state") == enums.PAYMENT_STATE_PAID + for installment in order.payment_schedule + ) + + +def get_paid_transactions(order): + """ + Return a transactions queryset that are made from the order on paid installments. + """ + return ( + Transaction.objects.filter( + invoice__order=order, + invoice__parent__isnull=False, + ) + .distinct() + .order_by("created_on") + .select_related("invoice__order") + ) + + +def get_installment_matching_transaction_amount(order, transaction): + """ + Return the installment that matches the transaction amount of an order's payment schedule. + """ + return next( + ( + installment + for installment in order.payment_schedule + if ( + installment["state"] == enums.PAYMENT_STATE_PAID + and installment["amount"] == transaction.total + ) + ), + None, + ) + + +def get_refundable_transactions(order) -> dict: + """ + Returns a dictionary with transaction reference as key and the amount of the installment + that is eligible to refund in an order's payment schedule. + """ + refund_items = {} + for transaction in get_paid_transactions(order): + matching_installment = get_installment_matching_transaction_amount( + order, transaction + ) + if matching_installment and transaction.reference not in refund_items: + refund_items[transaction.reference] = matching_installment["amount"] + return refund_items + + +def handle_refunded_transaction( + invoice, + amount: Decimal, + refund_reference: str, + is_transaction_canceled: bool, +): + """ + Handle the refund of an installment by creating a credit note, a transaction to reflect + the cash movement. + """ + # Create the credit note + credit_note = Invoice.objects.create( + order=invoice.order, + parent=invoice.order.main_invoice, + total=-amount, + recipient_address=invoice.recipient_address, + ) + # Create the transaction of the refund + Transaction.objects.create( + total=credit_note.total, + invoice=credit_note, + reference=refund_reference + if not is_transaction_canceled + else f"cancel_{refund_reference}", + ) diff --git a/src/backend/joanie/debug/views.py b/src/backend/joanie/debug/views.py index c4aed928c..546dca262 100644 --- a/src/backend/joanie/debug/views.py +++ b/src/backend/joanie/debug/views.py @@ -15,7 +15,6 @@ 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 @@ -38,7 +37,7 @@ 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.models import CreditCard, Invoice, Transaction +from joanie.payment.models import CreditCard, Invoice logger = getLogger(__name__) LOGO_FALLBACK = ( @@ -107,19 +106,6 @@ def get_context_data(self, **kwargs): 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) @@ -489,7 +475,6 @@ def get_context_data(self, **kwargs): """ context = super().get_context_data() backend = get_payment_backend() - random.reseed_random("reproductible_seed") owner = UserFactory(username="test_card", email="john.doe@acme.org") product = ProductFactory(price=Decimal("123.45")) diff --git a/src/backend/joanie/payment/backends/base.py b/src/backend/joanie/payment/backends/base.py index 56b20c38b..3c0fbbc44 100644 --- a/src/backend/joanie/payment/backends/base.py +++ b/src/backend/joanie/payment/backends/base.py @@ -10,8 +10,8 @@ from stockholm import Money -from joanie.core.enums import ORDER_STATE_COMPLETED -from joanie.core.utils import emails +from joanie.core.enums import ORDER_STATE_COMPLETED, ORDER_STATE_REFUND +from joanie.core.utils import emails, payment_schedule from joanie.payment.enums import INVOICE_STATE_REFUNDED from joanie.payment.models import Invoice, Transaction @@ -174,29 +174,27 @@ def _do_on_payment_failure(cls, order, installment_id): cls._send_mail_refused_debit(order, installment_id) @staticmethod - def _do_on_refund(amount, invoice, refund_reference): + def _do_on_refund( + amount, invoice, refund_reference, installment_id, is_transaction_canceled=False + ): """ Generic actions triggered when a refund has been received. Create a credit transaction then cancel the related order if sum of credit transactions is equal to the invoice amount. """ - - # - Create a credit note - credit_note = Invoice.objects.create( - order=invoice.order, - parent=invoice, - total=-amount, - recipient_address=invoice.recipient_address, + payment_schedule.handle_refunded_transaction( + invoice=invoice, + amount=amount, + refund_reference=refund_reference, + is_transaction_canceled=is_transaction_canceled, ) - Transaction.objects.create( - total=credit_note.total, - invoice=credit_note, - reference=refund_reference, - ) + invoice.order.set_installment_refunded(installment_id) - if invoice.state == INVOICE_STATE_REFUNDED: - # order has been fully refunded + if ( + invoice.state == INVOICE_STATE_REFUNDED + and invoice.order.state != ORDER_STATE_REFUND + ): invoice.order.flow.cancel() @staticmethod @@ -265,3 +263,11 @@ def tokenize_card(self, order=None, billing_address=None, user=None): raise NotImplementedError( "subclasses of BasePaymentBackend must provide a tokenize_card() method." ) + + def cancel_or_refund(self, amount: Money, transaction_reference: str): + """ + Method called to cancel or refund installments from an order payment schedule. + """ + raise NotImplementedError( + "subclasses of BasePaymentBackend must provide a cancel_or_refund() method." + ) diff --git a/src/backend/joanie/payment/backends/dummy.py b/src/backend/joanie/payment/backends/dummy.py index 1309cd745..68eb24348 100644 --- a/src/backend/joanie/payment/backends/dummy.py +++ b/src/backend/joanie/payment/backends/dummy.py @@ -112,6 +112,7 @@ def _treat_refund(self, resource, amount): amount=D(f"{amount / 100:.2f}"), invoice=payment.invoice.order.main_invoice, refund_reference=f"ref_{timezone.now().timestamp():.0f}", + installment_id=resource.get("metadata").get("installment_id"), ) @classmethod @@ -316,3 +317,36 @@ def tokenize_card(self, order=None, billing_address=None, user=None): # pylint: "customer": str(user.id), "card_token": f"card_{user.id}", } + + def cancel_or_refund(self, amount, transaction_reference): + """ + Dummy method to refund an installment by taking the transction reference (`payment_id` + in the cache). This method only treats a refund. + """ + resource = cache.get(transaction_reference) + if not resource: + raise exceptions.RegisterPaymentFailed( + f"Resource {transaction_reference} does not exist, cannot refund." + ) + + transaction = Transaction.objects.get(reference=transaction_reference) + if transaction.total != amount: + raise exceptions.RefundPaymentFailed( + f"Resource {transaction_reference} amount does not match the amount to refund" + ) + + # Trigger post notification for Dummy usage + notification_request = APIRequestFactory().post( + reverse("payment_webhook"), + data={ + "id": transaction.reference, + "type": "refund", + "state": "success", + }, + format="json", + ) + notification_request.data = json.loads( + notification_request.body.decode("utf-8") + ) + + return True diff --git a/src/backend/joanie/payment/backends/lyra/__init__.py b/src/backend/joanie/payment/backends/lyra/__init__.py index 37c43f859..cbc0819dc 100644 --- a/src/backend/joanie/payment/backends/lyra/__init__.py +++ b/src/backend/joanie/payment/backends/lyra/__init__.py @@ -9,11 +9,14 @@ from decimal import Decimal as D from django.conf import settings +from django.db.models import Q import requests from rest_framework.parsers import FormParser, JSONParser +from stockholm import Money from joanie.core.models import ActivityLog, Address, Order, User +from joanie.core.utils import payment_schedule from joanie.payment import exceptions from joanie.payment.backends.base import BasePaymentBackend from joanie.payment.models import CreditCard, Invoice, Transaction @@ -118,7 +121,12 @@ def _call_api(self, url, payload): logger.info("Calling Lyra API %s", url, extra={"context": context}) try: - response = requests.post(url, json=payload, headers=self.headers, timeout=5) + response = requests.post( + url, + json=payload, + headers=self.headers, + timeout=settings.JOANIE_PAYMENT_TIMEOUT, + ) response.raise_for_status() except requests.exceptions.RequestException as e: context = context.copy() @@ -370,13 +378,16 @@ def handle_notification(self, request): initial_issuer_transaction_identifier = card_details[ "initialIssuerTransactionIdentifier" ] + operation_category = answer["transactions"][0]["detailedStatus"] + parent_transaction_id = answer["transactions"][0]["transactionDetails"].get( + "parentTransactionUuid", None + ) installment_id = None if ( answer["transactions"][0]["metadata"] and "installment_id" in answer["transactions"][0]["metadata"] ): installment_id = answer["transactions"][0]["metadata"]["installment_id"] - # Register card if user has requested it if card_token is not None and ( payment_method_source != "TOKEN" or creation_context == "VERIFICATION" @@ -397,12 +408,29 @@ def handle_notification(self, request): payment_provider=self.name, ) + amount = f"{answer['orderDetails']['orderTotalAmount'] / 100:.2f}" + # Refund a transaction operation + if creation_context != "VERIFICATION" and ( + (operation_category == "CANCELLED" and answer["orderStatus"] == "UNPAID") + or (creation_context == "REFUND" and answer["orderStatus"] == "PAID") + ): + transaction = Transaction.objects.get( + Q(reference=transaction_id) | Q(reference=parent_transaction_id) + ) + return self._do_on_refund( + amount=D(amount), + invoice=transaction.invoice.order.main_invoice, + refund_reference=transaction_id, + installment_id=installment_id, + is_transaction_canceled=not parent_transaction_id, + ) + if answer["orderStatus"] == "PAID": billing_details = answer["customer"]["billingDetails"] payment = { "id": transaction_id, - "amount": D(f"{answer['orderDetails']['orderTotalAmount'] / 100:.2f}"), + "amount": D(amount), "billing_address": { "address": billing_details["address"], "city": billing_details["city"], @@ -480,3 +508,32 @@ def abort_payment(self, payment_id): """ Abort a payment, nothing to do for Lyra """ + + def cancel_or_refund(self, amount: Money, transaction_reference: str): + """ + Cancels or refunds a transaction made on the order's payment schedule. + The payment provider determines whether the transaction can be canceled or + refunded on their side. If the transaction has not yet been captured at the customer's + bank, it can be canceled. However, if the transaction has already been captured, + it means the funds have been received, and a refund process can be initiated instead. + + https://docs.lyra.com/fr/rest/V4.0/api/playground/Transaction/CancelOrRefund + """ + url = f"{self.api_url}Transaction/CancelOrRefund" + + payload = { + "amount": int(amount.sub_units), + "currency": settings.DEFAULT_CURRENCY, + "uuid": str(transaction_reference), + "resolutionMode": "AUTO", + } + + response_json = self._call_api(url, payload) + + if response_json.get("status") != "SUCCESS": + raise exceptions.RegisterPaymentFailed( + f"The transaction reference {transaction_reference} does not " + "exist at the payment provider." + ) + + return True diff --git a/src/backend/joanie/payment/backends/payplug/__init__.py b/src/backend/joanie/payment/backends/payplug/__init__.py index c5f884375..3cb15b769 100644 --- a/src/backend/joanie/payment/backends/payplug/__init__.py +++ b/src/backend/joanie/payment/backends/payplug/__init__.py @@ -11,6 +11,9 @@ from payplug.exceptions import BadRequest, Forbidden, NotFound, UnknownAPIResource from joanie.core.models import Order +from joanie.core.utils.payment_schedule import ( + get_installment_matching_transaction_amount, +) from joanie.payment import exceptions from joanie.payment.backends.base import BasePaymentBackend from joanie.payment.models import CreditCard, Transaction @@ -135,10 +138,16 @@ def _treat_refund(self, resource): f"Payment {resource.payment_id} does not exist." ) from error + installment = get_installment_matching_transaction_amount( + payment.invoice.order, payment + ) + self._do_on_refund( amount=D(f"{resource.amount / 100:.2f}"), invoice=payment.invoice, refund_reference=resource.id, + installment_id=installment["id"], + is_transaction_canceled=False, ) def create_payment(self, order, installment, billing_address): @@ -225,7 +234,7 @@ def delete_credit_card(self, credit_card): "Content-Type": "appliation/json", "Payplug-Version": self.configuration.get("api_version"), }, - timeout=10, + timeout=settings.JOANIE_PAYMENT_TIMEOUT, ) if not response.ok: @@ -244,3 +253,8 @@ def abort_payment(self, payment_id): payplug.Payment.abort(payment_id) except (Forbidden, NotFound) as error: raise exceptions.AbortPaymentFailed(str(error)) from error + + def cancel_or_refund(self, amount, transaction_reference): + """ + Cancel or refund a transaction, nothing to do for Payplug. + """ diff --git a/src/backend/joanie/settings.py b/src/backend/joanie/settings.py index 24106f6ca..12be51a3c 100755 --- a/src/backend/joanie/settings.py +++ b/src/backend/joanie/settings.py @@ -452,6 +452,9 @@ class Base(Configuration): environ_name="JOANIE_INSTALLMENT_REMINDER_DAYS_BEFORE", environ_prefix=None, ) + JOANIE_PAYMENT_TIMEOUT = values.PositiveIntegerValue( + 10, environ_name="JOANIE_PAYMENT_TIMEOUT", environ_prefix=None + ) # CORS CORS_ALLOW_CREDENTIALS = True diff --git a/src/backend/joanie/tests/core/models/order/test_factory.py b/src/backend/joanie/tests/core/models/order/test_factory.py index 3b0796eaf..5c597ceba 100644 --- a/src/backend/joanie/tests/core/models/order/test_factory.py +++ b/src/backend/joanie/tests/core/models/order/test_factory.py @@ -13,6 +13,7 @@ ORDER_STATE_NO_PAYMENT, ORDER_STATE_PENDING, ORDER_STATE_PENDING_PAYMENT, + ORDER_STATE_REFUND, ORDER_STATE_SIGNING, ORDER_STATE_TO_SAVE_PAYMENT_METHOD, ORDER_STATE_TO_SIGN, @@ -36,7 +37,7 @@ class TestOrderGeneratorFactory(TestCase): """Test suite for the OrderGeneratorFactory.""" - # pylint: disable=too-many-arguments, too-many-positional-arguments + # pylint: disable=too-many-arguments # ruff: noqa: PLR0913 def check_order( self, @@ -305,6 +306,23 @@ def test_factory_order_generator_create_transaction_and_children_invoice_when_st self.assertEqual(Transaction.objects.filter(invoice__order=order).count(), 4) self.assertEqual(Invoice.objects.filter(parent=order.main_invoice).count(), 4) + def test_factory_order_state_refund(self): + """ + When passing the state `refund` to the `OrderGeneratorFactory`, it should + create an order with a payment schedule where 1 installment has been paid. + """ + order = self.check_order( + ORDER_STATE_REFUND, + has_organization=True, + has_unsigned_contract=False, + is_free=False, + has_payment_method=True, + ) + + self.assertEqual(order.state, "refund") + self.assertEqual(order.payment_schedule[0]["state"], PAYMENT_STATE_PAID) + self.assertEqual(order.payment_schedule[1]["state"], PAYMENT_STATE_PENDING) + class TestOrderFactory(TestCase): """Test suite for the `OrderFactory`.""" 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 80ec08bf7..003c7214d 100644 --- a/src/backend/joanie/tests/core/models/order/test_schedule.py +++ b/src/backend/joanie/tests/core/models/order/test_schedule.py @@ -17,6 +17,7 @@ from joanie.core import factories from joanie.core.enums import ( + ORDER_STATE_CANCELED, ORDER_STATE_COMPLETED, ORDER_STATE_FAILED_PAYMENT, ORDER_STATE_NO_PAYMENT, @@ -24,6 +25,7 @@ ORDER_STATE_PENDING_PAYMENT, PAYMENT_STATE_PAID, PAYMENT_STATE_PENDING, + PAYMENT_STATE_REFUNDED, PAYMENT_STATE_REFUSED, ) from joanie.core.models import Order @@ -35,7 +37,7 @@ @override_settings( JOANIE_PAYMENT_SCHEDULE_LIMITS={ 5: (30, 70), - 10: (30, 45, 45), + 10: (30, 35, 35), 100: (20, 30, 30, 20), }, DEFAULT_CURRENCY="EUR", @@ -221,19 +223,19 @@ def test_models_order_schedule_3_parts(self): [ { "id": first_uuid, - "amount": Money(1.80, settings.DEFAULT_CURRENCY), + "amount": Money("1.80"), "due_date": date(2024, 1, 17), "state": PAYMENT_STATE_PENDING, }, { "id": second_uuid, - "amount": Money(2.70, settings.DEFAULT_CURRENCY), + "amount": Money("2.10"), "due_date": date(2024, 3, 1), "state": PAYMENT_STATE_PENDING, }, { "id": third_uuid, - "amount": Money(1.50, settings.DEFAULT_CURRENCY), + "amount": Money("2.10"), "due_date": date(2024, 4, 1), "state": PAYMENT_STATE_PENDING, }, @@ -252,13 +254,13 @@ def test_models_order_schedule_3_parts(self): }, { "id": str(second_uuid), - "amount": Money("2.70"), + "amount": Money("2.10"), "due_date": date(2024, 3, 1), "state": PAYMENT_STATE_PENDING, }, { "id": str(third_uuid), - "amount": Money("1.50"), + "amount": Money("2.10"), "due_date": date(2024, 4, 1), "state": PAYMENT_STATE_PENDING, }, @@ -297,19 +299,19 @@ def test_models_order_schedule_3_parts_session_already_started(self): [ { "id": first_uuid, - "amount": Money(1.80, settings.DEFAULT_CURRENCY), + "amount": Money("1.80"), "due_date": date(2024, 1, 1), "state": PAYMENT_STATE_PENDING, }, { "id": second_uuid, - "amount": Money(2.70, settings.DEFAULT_CURRENCY), + "amount": Money("2.10"), "due_date": date(2024, 2, 1), "state": PAYMENT_STATE_PENDING, }, { "id": third_uuid, - "amount": Money(1.50, settings.DEFAULT_CURRENCY), + "amount": Money("2.10"), "due_date": date(2024, 3, 1), "state": PAYMENT_STATE_PENDING, }, @@ -328,13 +330,13 @@ def test_models_order_schedule_3_parts_session_already_started(self): }, { "id": str(second_uuid), - "amount": Money("2.70"), + "amount": Money("2.10"), "due_date": date(2024, 2, 1), "state": PAYMENT_STATE_PENDING, }, { "id": str(third_uuid), - "amount": Money("1.50"), + "amount": Money("2.10"), "due_date": date(2024, 3, 1), "state": PAYMENT_STATE_PENDING, }, @@ -1399,3 +1401,78 @@ def test_models_order_get_index_of_installment_should_return_none_because_no_pen order.payment_schedule[1]["state"] = PAYMENT_STATE_REFUSED self.assertIsNone(order.get_installment_index(PAYMENT_STATE_PENDING)) + + def test_models_order_set_installment_state_refunded(self): + """ + Check that the state of an installment can be set to refunded only + if the installment was in state `paid`, else it raises an error. + """ + order = factories.OrderGeneratorFactory( + state=ORDER_STATE_CANCELED, + product__price=10, + ) + # First installment is paid + order.payment_schedule[0]["id"] = "d9356dd7-19a6-4695-b18e-ad93af41424a" + order.payment_schedule[0]["state"] = PAYMENT_STATE_PAID + order.payment_schedule[0]["due_date"] = date(2024, 2, 17) + # Second installment is pending for payment + order.payment_schedule[1]["id"] = "1932fbc5-d971-48aa-8fee-6d637c3154a5" + order.payment_schedule[1]["state"] = PAYMENT_STATE_PENDING + order.payment_schedule[1]["due_date"] = date(2024, 3, 17) + # Third installment is pending for payment + order.payment_schedule[2]["id"] = "168d7e8c-a1a9-4d70-9667-853bf79e502c" + order.payment_schedule[2]["state"] = PAYMENT_STATE_PENDING + order.payment_schedule[2]["due_date"] = date(2024, 4, 17) + + order.set_installment_refunded( + installment_id="d9356dd7-19a6-4695-b18e-ad93af41424a", + ) + + order.refresh_from_db() + self.assertEqual(order.state, ORDER_STATE_CANCELED) + self.assertEqual( + order.payment_schedule, + [ + { + "id": "d9356dd7-19a6-4695-b18e-ad93af41424a", + "amount": Money("3.00"), + "due_date": date(2024, 2, 17), + "state": PAYMENT_STATE_REFUNDED, + }, + { + "id": "1932fbc5-d971-48aa-8fee-6d637c3154a5", + "amount": Money("3.50"), + "due_date": date(2024, 3, 17), + "state": PAYMENT_STATE_PENDING, + }, + { + "id": "168d7e8c-a1a9-4d70-9667-853bf79e502c", + "amount": Money("3.50"), + "due_date": date(2024, 4, 17), + "state": PAYMENT_STATE_PENDING, + }, + ], + ) + + # Attempt to refund an installment that is still in `pending_payment` state + # should raise a Value Error + with self.assertRaises(ValueError) as context: + order.set_installment_refunded( + installment_id="1932fbc5-d971-48aa-8fee-6d637c3154a5", + ) + + self.assertEqual( + str(context.exception), + "Installment with id 1932fbc5-d971-48aa-8fee-6d637c3154a5 cannot be refund", + ) + + # Passing a fake installment id should raise a ValueError + with self.assertRaises(ValueError) as context: + order.set_installment_refunded( + installment_id="fake_installment_id", + ) + + self.assertEqual( + str(context.exception), + "Installment with id fake_installment_id cannot be refund", + ) 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..09f61d07b 100644 --- a/src/backend/joanie/tests/core/test_api_admin_orders.py +++ b/src/backend/joanie/tests/core/test_api_admin_orders.py @@ -1,5 +1,6 @@ """Test suite for the admin orders API endpoints.""" +import json import uuid from datetime import timedelta from decimal import Decimal as D @@ -8,13 +9,17 @@ from django.conf import settings from django.test import TestCase, override_settings +from django.urls import reverse from django.utils import timezone +from rest_framework.test import APIRequestFactory + from joanie.core import enums, factories from joanie.core.models import Order from joanie.core.models.certifications import Certificate from joanie.core.models.courses import CourseState, Enrollment from joanie.lms_handler.backends.dummy import DummyLMSBackend +from joanie.payment.backends.dummy import DummyPaymentBackend from joanie.payment.factories import InvoiceFactory from joanie.payment.models import Invoice from joanie.tests import format_date @@ -1525,3 +1530,323 @@ 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_an_order(self): + """ + Authenticated admin users should be able to cancel an order. + The order's state should be `canceled` after calling the endpoint. + """ + order_state_choices = tuple( + choice + for choice in enums.ORDER_STATE_CHOICES + if choice[0] + not in [ + enums.ORDER_STATE_DRAFT, + enums.ORDER_STATE_REFUND, + enums.ORDER_STATE_CANCELED, + ] + ) + admin = factories.UserFactory(is_staff=True, is_superuser=True) + self.client.login(username=admin.username, password="password") + + for state, _ in order_state_choices: + with self.subTest(state=state): + order = factories.OrderGeneratorFactory(state=state) + + 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_refund_request_without_authentication(self): + """ + Anonymous users should not be able to request a refund of an order. + """ + order = factories.OrderGeneratorFactory(state=enums.ORDER_STATE_PENDING_PAYMENT) + + response = self.client.post(f"/api/v1.0/admin/orders/{order.id}/refund/") + + self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED) + + def test_api_admin_orders_refund_request_with_lambda_user(self): + """ + Lambda users should not be able to request a refund of an order. + """ + 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}/refund/") + + self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) + + def test_api_admin_orders_refund_with_an_invalid_order_id(self): + """ + Authenticated admin user should not to refund 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/refund/") + + self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND) + + def test_api_admin_orders_refund_with_get_method_is_not_allowed(self): + """ + Authenticated admin users should not be able to use the get method to request to refund + an order. + """ + 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}/refund/") + + self.assertEqual(response.status_code, HTTPStatus.METHOD_NOT_ALLOWED) + self.assertEqual(order.state, enums.ORDER_STATE_PENDING_PAYMENT) + + def test_api_admin_orders_refund_with_put_method_is_not_allowed(self): + """ + Authenticated admin users should not be able to use the put method to update the request + to refund an order. + """ + 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}/refund/") + + self.assertEqual(response.status_code, HTTPStatus.METHOD_NOT_ALLOWED) + self.assertEqual(order.state, enums.ORDER_STATE_PENDING_PAYMENT) + + def test_api_admin_orders_refund_with_patch_method_is_not_allowed(self): + """ + Authenticated admin users should not be able to use the patch method to update a request + to refund an order. + """ + 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}/refund/") + + self.assertEqual(response.status_code, HTTPStatus.METHOD_NOT_ALLOWED) + self.assertEqual(order.state, enums.ORDER_STATE_PENDING_PAYMENT) + + def test_api_admin_orders_refund_with_delete_method_is_not_allowed(self): + """ + Authenticated admin users should not be able to use the delete method to refund an order. + """ + 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}/refund/") + + self.assertEqual(response.status_code, HTTPStatus.METHOD_NOT_ALLOWED) + self.assertEqual(order.state, enums.ORDER_STATE_PENDING_PAYMENT) + + def test_api_admin_orders_refund_an_order_not_possible_if_state_is_not_canceled( + self, + ): + """ + Authenticated admin users should not be able to refund an order if the state + is other than `canceled`. + """ + order_state_choices = tuple( + choice + for choice in enums.ORDER_STATE_CHOICES + if choice[0] + not in [ + enums.ORDER_STATE_CANCELED, + ] + ) + admin = factories.UserFactory(is_staff=True, is_superuser=True) + self.client.login(username=admin.username, password="password") + + for state, _ in order_state_choices: + with self.subTest(state=state): + order = factories.OrderGeneratorFactory(state=state) + + response = self.client.post( + f"/api/v1.0/admin/orders/{order.id}/refund/" + ) + + order.refresh_from_db() + self.assertEqual(response.status_code, HTTPStatus.UNPROCESSABLE_ENTITY) + self.assertEqual(order.state, state) + + def test_api_admin_orders_refund_an_order_not_possible_if_no_installment_is_paid( + self, + ): + """ + Authenticated admin users should not be able to refund an order if no installment + has been paid in the payment schedule. It should return a Bad Request error. + """ + 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) + order.flow.cancel() + + response = self.client.post(f"/api/v1.0/admin/orders/{order.id}/refund/") + + self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) + + def test_api_admin_orders_refund_an_order(self): + """ + Authenticated admin users should be able to refund an order when the state is `canceled`. + Once the treatment is done, the order's state should be `refund` and the installment + state should be set to `refunded`. + """ + backend = DummyPaymentBackend() + request_factory = APIRequestFactory() + 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, product__price=100 + ) + # Create the payment + payment_id = backend.create_payment( + order=order, + installment=order.payment_schedule[0], + billing_address=order.main_invoice.recipient_address.to_dict(), + )["payment_id"] + + # Notify that payment has been paid + request = request_factory.post( + reverse("payment_webhook"), + data={ + "id": payment_id, + "type": "payment", + "state": "success", + "installment_id": order.payment_schedule[0]["id"], + }, + format="json", + ) + request.data = json.loads(request.body.decode("utf-8")) + backend.handle_notification(request) + + # The installment's state should be in state 'paid + order.refresh_from_db() + self.assertEqual(order.payment_schedule[0]["state"], enums.PAYMENT_STATE_PAID) + + order.flow.cancel() + + response = self.client.post(f"/api/v1.0/admin/orders/{order.id}/refund/") + + order.refresh_from_db() + self.assertEqual(response.status_code, HTTPStatus.ACCEPTED) + self.assertEqual(order.state, enums.ORDER_STATE_REFUND) + + # Notify that payment has been refunded + request = request_factory.post( + reverse("payment_webhook"), + data={ + "id": payment_id, + "type": "refund", + "amount": order.payment_schedule[0]["amount"].sub_units, + "installment_id": order.payment_schedule[0]["id"], + }, + format="json", + ) + request.data = json.loads(request.body.decode("utf-8")) + + backend.handle_notification(request) + + order.refresh_from_db() + self.assertEqual( + order.payment_schedule[0]["state"], enums.PAYMENT_STATE_REFUNDED + ) diff --git a/src/backend/joanie/tests/core/test_flows_order.py b/src/backend/joanie/tests/core/test_flows_order.py index d973d7cd0..6a50f16c4 100644 --- a/src/backend/joanie/tests/core/test_flows_order.py +++ b/src/backend/joanie/tests/core/test_flows_order.py @@ -1429,7 +1429,6 @@ def test_flows_order_update(self): 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 @@ -1675,3 +1674,60 @@ def test_flows_order_pending_transition_should_not_trigger_payment_if_due_date_i mock_create_zero_click_payment.assert_not_called() self.assertEqual(order.state, enums.ORDER_STATE_PENDING) + + @override_settings( + JOANIE_PAYMENT_SCHEDULE_LIMITS={ + 5: (30, 70), + }, + ) + def test_flows_order_canceled_should_be_able_to_be_refund_when_order_is_not_free( + self, + ): + """ + Test that the refund flow method should set an order to state `refund` + only if the order was in state `canceled` and has at least 1 installment paid. + """ + order = factories.OrderGeneratorFactory( + state=enums.ORDER_STATE_PENDING_PAYMENT, + product__price=5, + ) + order.flow.cancel() + + order.flow.refund() + + self.assertEqual(order.state, enums.ORDER_STATE_REFUND) + + @override_settings( + JOANIE_PAYMENT_SCHEDULE_LIMITS={ + 5: (30, 70), + }, + ) + def test_flows_order_canceled_should_stay_canceled_when_the_order_is_free(self): + """ + Test when a free order is in state `canceled`, it can't go to state `refund`. + """ + order = factories.OrderGeneratorFactory( + product__price=0, state=enums.ORDER_STATE_CANCELED + ) + + with self.assertRaises(TransitionNotAllowed): + order.flow.refund() + + self.assertEqual(order.state, enums.ORDER_STATE_CANCELED) + + def test_flows_order_refund_failure_when_state_is_not_canceled(self): + """ + Test that the refund flow transition for an order does not work with states other + than `canceled`. + """ + order_state_choices = tuple( + choice + for choice in enums.ORDER_STATE_CHOICES + if choice[0] not in (enums.ORDER_STATE_REFUND, enums.ORDER_STATE_CANCELED) + ) + for state, _ in order_state_choices: + with self.subTest(state=state): + order = factories.OrderGeneratorFactory(state=state) + with self.assertRaises(TransitionNotAllowed): + order.flow.refund() + self.assertEqual(order.state, state) 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 1791278cf..53d5c2a3e 100644 --- a/src/backend/joanie/tests/core/test_utils_payment_schedule.py +++ b/src/backend/joanie/tests/core/test_utils_payment_schedule.py @@ -2,6 +2,7 @@ Test suite for payment schedule util """ +# pylint: disable=too-many-lines import uuid from datetime import date, datetime, timedelta from decimal import Decimal as D @@ -18,12 +19,15 @@ from joanie.core import factories from joanie.core.enums import ( + ORDER_STATE_PENDING, 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.payment.factories import InvoiceFactory, TransactionFactory +from joanie.payment.models import Invoice, Transaction from joanie.tests.base import BaseLogMixinTestCase # pylint: disable=protected-access, too-many-public-methods @@ -973,3 +977,264 @@ def test_utils_send_mail_reminder_for_installment_debit_should_use_fallback_lang email_content = " ".join(mail.outbox[0].body.split()) self.assertIn("Product 1", email_content) self.assertIn("30.00", email_content) + + def test_utils_payment_schedule_order_has_installment_paid(self): + """ + Check the method `has_installment_paid` returns whether the order has + an installment paid or not. + """ + product_1 = factories.ProductFactory(price=100) + order_1 = factories.OrderGeneratorFactory( + product=product_1, + state=ORDER_STATE_PENDING_PAYMENT, + ) + + self.assertTrue(payment_schedule.has_installment_paid(order_1)) + + product_2 = factories.ProductFactory(price=0) + order_2 = factories.OrderGeneratorFactory( + product=product_2, + state=ORDER_STATE_PENDING, + ) + + self.assertFalse(payment_schedule.has_installment_paid(order_2)) + + def test_utils_payment_schedule_get_paid_transactionst(self): + """ + The method `get_paid_transactions` should return every transactions + that are concerned in the order's payment schedule with 'paid' state. + """ + product = factories.ProductFactory(price=100) + order = factories.OrderGeneratorFactory( + product=product, + state=ORDER_STATE_PENDING_PAYMENT, + ) + order.payment_schedule[1]["due_date"] = date(2024, 2, 17) + order.payment_schedule[1]["state"] = PAYMENT_STATE_PAID + order.payment_schedule[2]["due_date"] = date(2024, 3, 17) + order.payment_schedule[2]["state"] = PAYMENT_STATE_PAID + # Create the invoice and the transaction for 'paid' state installments + for i, payment in enumerate(order.payment_schedule[1:-1]): + child_invoice = InvoiceFactory( + order=order, + total=0, + parent=order.invoices.get(parent__isnull=True), + recipient_address=order.main_invoice.recipient_address, + ) + TransactionFactory( + total=D(str(payment["amount"])), + invoice=child_invoice, + reference=f"pay_{i}", + ) + + transactions = payment_schedule.get_paid_transactions(order) + + # We should find 3 transactions + self.assertEqual(transactions.count(), 3) + self.assertEqual(transactions[0].total, 20) + self.assertEqual(transactions[1].total, 30) + self.assertEqual(transactions[2].total, 30) + + def test_utils_payment_schedule_get_transactions_of_installment_should_not_return_transactions( + self, + ): + """ + When the order is free, it `get_paid_transactions` should not return a queryset + of transactions. + """ + order = factories.OrderGeneratorFactory( + product__price=0, + state=ORDER_STATE_PENDING, + ) + + transactions = payment_schedule.get_paid_transactions(order) + + self.assertEqual(transactions.count(), 0) + + def test_utils_payment_schedule_handle_refunded_transaction(self): + """ + Should create a credit note invoice and transaction showing amount of the credit + when we are in the context to refund a transaction in the payment schedule of an order. + """ + order = factories.OrderGeneratorFactory( + state=ORDER_STATE_PENDING_PAYMENT, product__price=100 + ) + + # Should have 1 main Invoice and 1 child Invoice + self.assertEqual(Invoice.objects.filter(order=order).count(), 2) + # Should have 1 Transaction showing the debit transaction of the only paid installment + self.assertEqual(Transaction.objects.filter(invoice__order=order).count(), 1) + + child_invoice = Invoice.objects.get(order=order, parent__isnull=True) + transaction = Transaction.objects.get(invoice__order=order) + # Cancel the order first and then switch the state to 'refund' + order.flow.cancel() + order.flow.refund() + order_id_fragment = str(order.id).split("-", maxsplit=1)[0] + + # Should create 1 new transaction et 1 credit note Invoice + payment_schedule.handle_refunded_transaction( + amount=D(transaction.total), + invoice=child_invoice, + refund_reference=f"{order_id_fragment}", + is_transaction_canceled=False, + ) + + # Should have 1 main Invoice and 1 child Invoice + self.assertEqual(Invoice.objects.filter(order=order).count(), 3) + # Should have 1 Transaction showing the debit transaction of the only paid installment + self.assertEqual(Transaction.objects.filter(invoice__order=order).count(), 2) + self.assertEqual( + Invoice.objects.filter( + order=order, total=-D(str(order.payment_schedule[0]["amount"])) + ).count(), + 1, + ) + self.assertEqual( + Transaction.objects.filter(reference=order_id_fragment).count(), 1 + ) + + def test_utils_payment_schedule_handle_refunded_transaction_when_is_cancelled(self): + """ + Should create a credit note invoice and transaction showing amount of the credit + when we are in the context of cancelling a transaction in the payment schedule of an + order. In that specific case, the cancelled transaction should have the prefix in the + reference field `cancel_`. + """ + order = factories.OrderGeneratorFactory( + state=ORDER_STATE_PENDING_PAYMENT, product__price=100 + ) + + # Should have 1 main Invoice and 1 child Invoice + self.assertEqual(Invoice.objects.filter(order=order).count(), 2) + # Should have 1 Transaction showing the debit transaction of the only paid installment + self.assertEqual(Transaction.objects.filter(invoice__order=order).count(), 1) + + child_invoice = Invoice.objects.get(order=order, parent__isnull=True) + transaction = Transaction.objects.get(invoice__order=order) + # Cancel the order first and then switch the state to 'refund' + order.flow.cancel() + order.flow.refund() + order_id_fragment = str(order.id).split("-", maxsplit=1)[0] + + # Should create 1 new transaction et 1 credit note Invoice + payment_schedule.handle_refunded_transaction( + amount=D(transaction.total), + invoice=child_invoice, + refund_reference=f"{order_id_fragment}", + is_transaction_canceled=True, + ) + + # Should have 1 main Invoice and 1 child Invoice + self.assertEqual(Invoice.objects.filter(order=order).count(), 3) + # Should have 1 Transaction showing the debit transaction of the only paid installment + self.assertEqual(Transaction.objects.filter(invoice__order=order).count(), 2) + self.assertEqual( + Invoice.objects.filter( + order=order, total=-D(str(order.payment_schedule[0]["amount"])) + ).count(), + 1, + ) + self.assertEqual( + Transaction.objects.filter(reference=f"cancel_{order_id_fragment}").count(), + 1, + ) + + def test_utils_payment_schedule_get_refundable_transactions_returns_empty_dictionary( + self, + ): + """ + The method `get_refundable_transactions` should return an empty dictionary + if there is no paid installment on an order payment schedule. + """ + order = factories.OrderGeneratorFactory( + state=ORDER_STATE_PENDING, product__price=1000 + ) + + refund_items = payment_schedule.get_refundable_transactions(order) + + self.assertEqual(refund_items, {}) + + def test_utils_payment_schedule_get_refundable_transactions(self): + """ + The method `get_refundable_transactions` should return the installments + that are refundable in the payment schedule of an order. + """ + order = factories.OrderGeneratorFactory( + state=ORDER_STATE_PENDING_PAYMENT, product__price=1000 + ) + order.payment_schedule[1]["state"] = PAYMENT_STATE_PAID + order.payment_schedule[2]["state"] = PAYMENT_STATE_PAID + transaction_1 = Transaction.objects.get( + reference=order.payment_schedule[0]["id"] + ) + # Create transaction and invoice for the second and third paid' installments + transaction_2 = TransactionFactory.create( + reference=order.payment_schedule[1]["id"], + invoice__order=order, + invoice__parent=order.main_invoice, + invoice__total=0, + invoice__recipient_address__owner=order.owner, + total=str(order.payment_schedule[1]["amount"]), + ) + transaction_3 = TransactionFactory.create( + reference=order.payment_schedule[2]["id"], + invoice__order=order, + invoice__parent=order.main_invoice, + invoice__total=0, + invoice__recipient_address__owner=order.owner, + total=str(order.payment_schedule[2]["amount"]), + ) + first_installment = order.payment_schedule[0] + second_installment = order.payment_schedule[1] + third_installment = order.payment_schedule[2] + + refund_items = payment_schedule.get_refundable_transactions(order) + + self.assertEqual( + refund_items, + { + str(transaction_1.reference): first_installment["amount"], + str(transaction_2.reference): second_installment["amount"], + str(transaction_3.reference): third_installment["amount"], + }, + ) + + def test_utils_payment_schedule_get_installment_matching_transaction_amount(self): + """ + When calling `get_installment_matching_transaction_amount` with an order + and a transaction, it returns the concerned installment that is in `paid` + state + """ + order = factories.OrderGeneratorFactory( + state=ORDER_STATE_PENDING_PAYMENT, product__price=1000 + ) + transaction_1 = Transaction.objects.get( + reference=order.payment_schedule[0]["id"] + ) + # Set the second installment as paid and create the transaction + order.payment_schedule[1]["state"] = PAYMENT_STATE_PAID + transaction_2 = TransactionFactory.create( + reference=order.payment_schedule[1]["id"], + invoice__order=order, + invoice__parent=order.main_invoice, + invoice__total=0, + invoice__recipient_address__owner=order.owner, + total=str(order.payment_schedule[1]["amount"]), + ) + + found_installment_1 = ( + payment_schedule.get_installment_matching_transaction_amount( + order, transaction_1 + ) + ) + + self.assertEqual(found_installment_1, order.payment_schedule[0]) + + found_installment_2 = ( + payment_schedule.get_installment_matching_transaction_amount( + order, transaction_2 + ) + ) + + self.assertEqual(found_installment_2, order.payment_schedule[1]) 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 267be1e1b..cc6edde00 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 += 11 # one order of each state + nb_product_credential += 12 # 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/payment/lyra/requests/cancel_transaction.json b/src/backend/joanie/tests/payment/lyra/requests/cancel_transaction.json new file mode 100644 index 000000000..4102b9633 --- /dev/null +++ b/src/backend/joanie/tests/payment/lyra/requests/cancel_transaction.json @@ -0,0 +1,7 @@ +{ + "kr-hash-key": "password", + "kr-hash-algorithm": "sha256_hmac", + "kr-answer": "{\"shopId\":\"69876357\",\"orderCycle\":\"CLOSED\",\"orderStatus\":\"UNPAID\",\"serverDate\":\"2024-10-04T08:31:39+00:00\",\"orderDetails\":{\"orderTotalAmount\":12345,\"orderEffectiveAmount\":12345,\"orderCurrency\":\"EUR\",\"mode\":\"TEST\",\"orderId\":\"a7834082-a000-4de4-af6e-e09683c9a752\",\"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\":\"d1053bae1aad463f8975ec248fa46eb3\",\"amount\":12345,\"currency\":\"EUR\",\"paymentMethodType\":\"CARD\",\"paymentMethodToken\":\"null\",\"status\":\"UNPAID\",\"detailedStatus\":\"CANCELLED\",\"operationType\":\"DEBIT\",\"effectiveStrongAuthentication\":\"DISABLED\",\"creationDate\":\"2024-10-04T08:31:39+00:00\",\"errorCode\":null,\"errorMessage\":null,\"detailedErrorCode\":null,\"detailedErrorMessage\":null,\"metadata\":{\"installment_id\": \"d9356dd7-19a6-4695-b18e-ad93af41424a\"},\"transactionDetails\":{\"liabilityShift\":\"YES\",\"effectiveAmount\":12345,\"effectiveCurrency\":\"EUR\",\"creationContext\":\"CHARGE\",\"cardDetails\":{\"paymentSource\":\"EC\",\"manualValidation\":\"NO\",\"expectedCaptureDate\":\"2024-10-04T08:31:39+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\":\"921615\",\"legacyTransDate\":\"2024-10-04T08:31:39+00:00\",\"paymentMethodSource\":\"NEW\",\"authorizationResponse\":{\"amount\":12345,\"currency\":\"EUR\",\"authorizationDate\":\"2024-10-04T08:31:39+00:00\",\"authorizationNumber\":\"3fe0d9\",\"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\":\"e15b4a50-94e7-4987-ab4e-da37b3d6129d\",\"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\":\"SUCCESS\",\"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\":\"CREDIT\",\"nature\":\"CONSUMER_CARD\",\"_type\":\"V4/PaymentMethod/Details/CardDetails\"},\"paymentMethodDetails\":{\"id\":\"497010XXXXXX0055\",\"paymentSource\":\"EC\",\"manualValidation\":\"NO\",\"expectedCaptureDate\":\"2024-10-04T08:31:39+00:00\",\"effectiveBrand\":\"VISA\",\"expiryMonth\":12,\"expiryYear\":2025,\"country\":\"FR\",\"issuerCode\":null,\"issuerName\":\"Banque de demo et de l'innovation\",\"effectiveProductCode\":\"F\",\"legacyTransId\":\"921615\",\"legacyTransDate\":\"2024-10-04T08:31:39+00:00\",\"paymentMethodSource\":\"NEW\",\"authorizationResponse\":{\"amount\":12345,\"currency\":\"EUR\",\"authorizationDate\":\"2024-10-04T08:31:39+00:00\",\"authorizationNumber\":\"3fe0d9\",\"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\":\"e15b4a50-94e7-4987-ab4e-da37b3d6129d\",\"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\":\"L10292161501\",\"useCase\":null,\"wallet\":null,\"_type\":\"V4/TransactionDetails\"},\"_type\":\"V4/PaymentTransaction\"}],\"subMerchantDetails\":null,\"_type\":\"V4/Payment\"}", + "kr-answer-type": "V4/Payment", + "kr-hash": "23741ce0cad7f7b4872d71f18fed9ff4d1998eb7981131a673ff4727c5ba2c54" +} diff --git a/src/backend/joanie/tests/payment/lyra/requests/cancel_transaction_answer.json b/src/backend/joanie/tests/payment/lyra/requests/cancel_transaction_answer.json new file mode 100644 index 000000000..b599e5885 --- /dev/null +++ b/src/backend/joanie/tests/payment/lyra/requests/cancel_transaction_answer.json @@ -0,0 +1,340 @@ +{ + "shopId": "69876357", + "orderCycle": "CLOSED", + "orderStatus": "UNPAID", + "serverDate": "2024-10-04T08:31:39+00:00", + "orderDetails": { + "orderTotalAmount": 12345, + "orderEffectiveAmount": 12345, + "orderCurrency": "EUR", + "mode": "TEST", + "orderId": "a7834082-a000-4de4-af6e-e09683c9a752", + "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": "d1053bae1aad463f8975ec248fa46eb3", + "amount": 12345, + "currency": "EUR", + "paymentMethodType": "CARD", + "paymentMethodToken": "null", + "status": "UNPAID", + "detailedStatus": "CANCELLED", + "operationType": "DEBIT", + "effectiveStrongAuthentication": "DISABLED", + "creationDate": "2024-10-04T08:31:39+00:00", + "errorCode": null, + "errorMessage": null, + "detailedErrorCode": null, + "detailedErrorMessage": null, + "metadata": { + "installment_id": "d9356dd7-19a6-4695-b18e-ad93af41424a" + }, + "transactionDetails": { + "liabilityShift": "YES", + "effectiveAmount": 12345, + "effectiveCurrency": "EUR", + "creationContext": "CHARGE", + "cardDetails": { + "paymentSource": "EC", + "manualValidation": "NO", + "expectedCaptureDate": "2024-10-04T08:31:39+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": "921615", + "legacyTransDate": "2024-10-04T08:31:39+00:00", + "paymentMethodSource": "NEW", + "authorizationResponse": { + "amount": 12345, + "currency": "EUR", + "authorizationDate": "2024-10-04T08:31:39+00:00", + "authorizationNumber": "3fe0d9", + "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": "e15b4a50-94e7-4987-ab4e-da37b3d6129d", + "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": "SUCCESS", + "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": "CREDIT", + "nature": "CONSUMER_CARD", + "_type": "V4/PaymentMethod/Details/CardDetails" + }, + "paymentMethodDetails": { + "id": "497010XXXXXX0055", + "paymentSource": "EC", + "manualValidation": "NO", + "expectedCaptureDate": "2024-10-04T08:31:39+00:00", + "effectiveBrand": "VISA", + "expiryMonth": 12, + "expiryYear": 2025, + "country": "FR", + "issuerCode": null, + "issuerName": "Banque de demo et de l'innovation", + "effectiveProductCode": "F", + "legacyTransId": "921615", + "legacyTransDate": "2024-10-04T08:31:39+00:00", + "paymentMethodSource": "NEW", + "authorizationResponse": { + "amount": 12345, + "currency": "EUR", + "authorizationDate": "2024-10-04T08:31:39+00:00", + "authorizationNumber": "3fe0d9", + "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": "e15b4a50-94e7-4987-ab4e-da37b3d6129d", + "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": "L10292161501", + "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/refund_accepted_answer.json b/src/backend/joanie/tests/payment/lyra/requests/refund_accepted_answer.json new file mode 100644 index 000000000..1a942e8e0 --- /dev/null +++ b/src/backend/joanie/tests/payment/lyra/requests/refund_accepted_answer.json @@ -0,0 +1,340 @@ +{ + "shopId": "69876357", + "orderCycle": "CLOSED", + "orderStatus": "PAID", + "serverDate": "2024-10-04T08:31:39+00:00", + "orderDetails": { + "orderTotalAmount": 12345, + "orderEffectiveAmount": 12345, + "orderCurrency": "EUR", + "mode": "TEST", + "orderId": "a7834082-a000-4de4-af6e-e09683c9a752", + "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": "50369f1f6c3f4ea6a451a41662688133", + "amount": 12345, + "currency": "EUR", + "paymentMethodType": "CARD", + "paymentMethodToken": "872e0b7d1a04489bb10bf977047d217b", + "status": "PAID", + "detailedStatus": "AUTHORISED", + "operationType": "CREDIT", + "effectiveStrongAuthentication": "DISABLED", + "creationDate": "2024-10-04T08:31:39+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": "REFUND", + "cardDetails": { + "paymentSource": "EC", + "manualValidation": "NO", + "expectedCaptureDate": "2024-10-04T08:31:39+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": "921615", + "legacyTransDate": "2024-10-04T08:31:39+00:00", + "paymentMethodSource": "NEW", + "authorizationResponse": { + "amount": 12345, + "currency": "EUR", + "authorizationDate": "2024-10-04T08:31:39+00:00", + "authorizationNumber": "3fe0d9", + "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": "e15b4a50-94e7-4987-ab4e-da37b3d6129d", + "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": "CREDIT", + "nature": "CONSUMER_CARD", + "_type": "V4/PaymentMethod/Details/CardDetails" + }, + "paymentMethodDetails": { + "id": "497010XXXXXX0055", + "paymentSource": "EC", + "manualValidation": "NO", + "expectedCaptureDate": "2024-10-04T08:31:39+00:00", + "effectiveBrand": "VISA", + "expiryMonth": 12, + "expiryYear": 2025, + "country": "FR", + "issuerCode": null, + "issuerName": "Banque de demo et de l'innovation", + "effectiveProductCode": "F", + "legacyTransId": "921615", + "legacyTransDate": "2024-10-04T08:31:39+00:00", + "paymentMethodSource": "NEW", + "authorizationResponse": { + "amount": 12345, + "currency": "EUR", + "authorizationDate": "2024-10-04T08:31:39+00:00", + "authorizationNumber": "3fe0d9", + "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": "e15b4a50-94e7-4987-ab4e-da37b3d6129d", + "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" + }, + "mid": "9876357", + "parentTransactionUuid": "dbf4b89a-e157-499e-83be-a366c91daaa8", + "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": "L10292161501", + "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/refund_accepted_transaction.json b/src/backend/joanie/tests/payment/lyra/requests/refund_accepted_transaction.json new file mode 100644 index 000000000..b7d2a0cfc --- /dev/null +++ b/src/backend/joanie/tests/payment/lyra/requests/refund_accepted_transaction.json @@ -0,0 +1,7 @@ +{ + "kr-hash-key": "password", + "kr-hash-algorithm": "sha256_hmac", + "kr-answer": "{\"shopId\":\"69876357\",\"orderCycle\":\"CLOSED\",\"orderStatus\":\"PAID\",\"serverDate\":\"2024-10-04T08:31:39+00:00\",\"orderDetails\":{\"orderTotalAmount\":12345,\"orderEffectiveAmount\":12345,\"orderCurrency\":\"EUR\",\"mode\":\"TEST\",\"orderId\":\"a7834082-a000-4de4-af6e-e09683c9a752\",\"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\":\"50369f1f6c3f4ea6a451a41662688133\",\"amount\":12345,\"currency\":\"EUR\",\"paymentMethodType\":\"CARD\",\"paymentMethodToken\":\"872e0b7d1a04489bb10bf977047d217b\",\"status\":\"PAID\",\"detailedStatus\":\"AUTHORISED\",\"operationType\":\"CREDIT\",\"effectiveStrongAuthentication\":\"DISABLED\",\"creationDate\":\"2024-10-04T08:31:39+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\":\"REFUND\",\"cardDetails\":{\"paymentSource\":\"EC\",\"manualValidation\":\"NO\",\"expectedCaptureDate\":\"2024-10-04T08:31:39+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\":\"921615\",\"legacyTransDate\":\"2024-10-04T08:31:39+00:00\",\"paymentMethodSource\":\"NEW\",\"authorizationResponse\":{\"amount\":12345,\"currency\":\"EUR\",\"authorizationDate\":\"2024-10-04T08:31:39+00:00\",\"authorizationNumber\":\"3fe0d9\",\"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\":\"e15b4a50-94e7-4987-ab4e-da37b3d6129d\",\"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\":\"CREDIT\",\"nature\":\"CONSUMER_CARD\",\"_type\":\"V4/PaymentMethod/Details/CardDetails\"},\"paymentMethodDetails\":{\"id\":\"497010XXXXXX0055\",\"paymentSource\":\"EC\",\"manualValidation\":\"NO\",\"expectedCaptureDate\":\"2024-10-04T08:31:39+00:00\",\"effectiveBrand\":\"VISA\",\"expiryMonth\":12,\"expiryYear\":2025,\"country\":\"FR\",\"issuerCode\":null,\"issuerName\":\"Banque de demo et de l'innovation\",\"effectiveProductCode\":\"F\",\"legacyTransId\":\"921615\",\"legacyTransDate\":\"2024-10-04T08:31:39+00:00\",\"paymentMethodSource\":\"NEW\",\"authorizationResponse\":{\"amount\":12345,\"currency\":\"EUR\",\"authorizationDate\":\"2024-10-04T08:31:39+00:00\",\"authorizationNumber\":\"3fe0d9\",\"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\":\"e15b4a50-94e7-4987-ab4e-da37b3d6129d\",\"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\",\"parentTransactionUuid\": \"dbf4b89a-e157-499e-83be-a366c91daaa8\",\"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\":\"L10292161501\",\"useCase\":null,\"wallet\":null,\"_type\":\"V4/TransactionDetails\"},\"_type\":\"V4/PaymentTransaction\"}],\"subMerchantDetails\":null,\"_type\":\"V4/Payment\"}", + "kr-answer-type": "V4/Payment", + "kr-hash": "b86d1a3bc0c11e032c4ae40fd51e7c46593956d6f0c9afa72b7bb584251f0b3b" +} diff --git a/src/backend/joanie/tests/payment/lyra/responses/cancel_and_refund_failed.json b/src/backend/joanie/tests/payment/lyra/responses/cancel_and_refund_failed.json new file mode 100644 index 000000000..bbd52402b --- /dev/null +++ b/src/backend/joanie/tests/payment/lyra/responses/cancel_and_refund_failed.json @@ -0,0 +1,22 @@ +{ + "webService": "Transaction/CancelOrRefund", + "version": "V4", + "applicationVersion": "6.22.1", + "status": "ERROR", + "answer": { + "errorCode": "PSP_010", + "errorMessage": "transaction not found", + "detailedErrorCode": null, + "detailedErrorMessage": null, + "ticket": "null", + "shopId": "69876357", + "_type": "V4/WebService/WebServiceError" + }, + "ticket": "eb1720062ea64f9583b42206df0cded3", + "serverDate": "2024-10-10T13:40:03+00:00", + "applicationProvider": "LYRA", + "metadata": null, + "mode": "TEST", + "serverUrl": "https://api.lyra.com", + "_type": "V4/WebService/Response" +} diff --git a/src/backend/joanie/tests/payment/lyra/responses/cancel_transaction_payment.json b/src/backend/joanie/tests/payment/lyra/responses/cancel_transaction_payment.json new file mode 100644 index 000000000..894cf2674 --- /dev/null +++ b/src/backend/joanie/tests/payment/lyra/responses/cancel_transaction_payment.json @@ -0,0 +1,295 @@ +{ + "webService": "Transaction/CancelOrRefund", + "version": "V4", + "applicationVersion": "6.21.0", + "status": "SUCCESS", + "answer": { + "shopId": "69876357", + "uuid": "d1053bae1aad463f8975ec248fa46eb3", + "paymentMethodType": "CARD", + "paymentMethodToken": null, + "detailedStatus": "CANCELLED", + "status": "UNPAID", + "amount": 3000, + "currency": "EUR", + "creationDate": "2024-09-30T16:43:51+00:00", + "errorCode": null, + "errorMessage": null, + "detailedErrorCode": null, + "detailedErrorMessage": null, + "effectiveStrongAuthentication": "DISABLED", + "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": "johndoe@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": "90.92.39.117", + "browserUserAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", + "_type": "V4/Customer/ExtraDetails" + }, + "shoppingCart": { + "insuranceAmount": null, + "shippingAmount": null, + "taxAmount": null, + "cartItemInfo": null, + "_type": "V4/Customer/ShoppingCart" + }, + "_type": "V4/Customer/Customer" + }, + "transactionDetails": { + "liabilityShift": "NO", + "effectiveAmount": 3000, + "effectiveCurrency": "EUR", + "creationContext": "CHARGE", + "cardDetails": { + "paymentSource": "EC", + "manualValidation": "NO", + "expectedCaptureDate": "2024-10-06T16:43:51+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": "905496", + "legacyTransDate": "2024-09-30T16:43:51+00:00", + "paymentMethodSource": "NEW", + "authorizationResponse": { + "amount": 1000, + "currency": "EUR", + "authorizationDate": "2024-09-30T16:43:51+00:00", + "authorizationNumber": "3fe584", + "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": "44911066-1a2f-436f-9b1b-44d9592e7339", + "protocol": { + "name": "THREEDS", + "version": "2.2.0", + "network": "VISA", + "challengePreference": "NO_PREFERENCE", + "simulation": true, + "_type": "V4/Charge/Authenticate/Protocol" + }, + "value": { + "status": "NOT_ENROLLED", + "extension": { + "authenticationType": "THREEDS_V2", + "requestedExemption": "TECHNICAL_ERROR", + "requestorName": "Demo shop", + "_type": "V4/Charge/Authenticate/AuthenticationResultExtensionThreedsV2" + }, + "_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-10-06T16:43:51+00:00", + "effectiveBrand": "VISA", + "expiryMonth": 12, + "expiryYear": 2025, + "country": "FR", + "issuerCode": null, + "issuerName": "Banque de demo et de l'innovation", + "effectiveProductCode": "F", + "legacyTransId": "905496", + "legacyTransDate": "2024-09-30T16:43:51+00:00", + "paymentMethodSource": "NEW", + "authorizationResponse": { + "amount": 990, + "currency": "EUR", + "authorizationDate": "2024-09-30T16:43:51+00:00", + "authorizationNumber": "3fe584", + "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": "44911066-1a2f-436f-9b1b-44d9592e7339", + "protocol": { + "name": "THREEDS", + "version": "2.2.0", + "network": "VISA", + "challengePreference": "NO_PREFERENCE", + "simulation": true, + "_type": "V4/Charge/Authenticate/Protocol" + }, + "value": { + "status": "NOT_ENROLLED", + "extension": { + "authenticationType": "THREEDS_V2", + "requestedExemption": "TECHNICAL_ERROR", + "requestorName": "Demo shop", + "_type": "V4/Charge/Authenticate/AuthenticationResultExtensionThreedsV2" + }, + "_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": [], + "_type": "V4/PaymentMethod/Details/FraudManagement" + }, + "parentTransactionUuid": null, + "mid": "9876357", + "sequenceNumber": 1, + "taxAmount": null, + "preTaxAmount": null, + "taxRate": null, + "externalTransactionId": null, + "nsu": null, + "tid": "001", + "acquirerNetwork": "CB", + "taxRefundAmount": null, + "userInfo": null, + "paymentMethodTokenPreviouslyRegistered": null, + "occurrenceType": "UNITAIRE", + "archivalReferenceId": "L27490549601", + "useCase": null, + "wallet": null, + "_type": "V4/TransactionDetails" + }, + "orderDetails": { + "orderTotalAmount": 3000, + "orderEffectiveAmount": null, + "orderCurrency": "EUR", + "mode": "TEST", + "orderId": "myOrderId-321", + "metadata": null, + "_type": "V4/OrderDetails" + }, + "operationType": "DEBIT", + "metadata": null, + "_type": "V4/Transaction" + }, + "ticket": "753889b9cc784f84b5339da9be5ede6f", + "serverDate": "2024-09-30T16:46:04+00:00", + "applicationProvider": "LYRA", + "metadata": null, + "mode": "TEST", + "serverUrl": "https://api.lyra.com", + "_type": "V4/WebService/Response" +} diff --git a/src/backend/joanie/tests/payment/lyra/responses/refund_transaction_payment.json b/src/backend/joanie/tests/payment/lyra/responses/refund_transaction_payment.json new file mode 100644 index 000000000..c5a8fdc8b --- /dev/null +++ b/src/backend/joanie/tests/payment/lyra/responses/refund_transaction_payment.json @@ -0,0 +1,255 @@ +{ + "webService": "Transaction/CancelOrRefund", + "version": "V4", + "applicationVersion": "6.21.0", + "status": "SUCCESS", + "answer": { + "shopId": "69876357", + "uuid": "3c28bc9cfea343a99edfcbde833f09b9", + "paymentMethodType": "CARD", + "paymentMethodToken": "d662d15e7ebf4682a22b8858b4f2dac0", + "detailedStatus": "AUTHORISED", + "status": "PAID", + "amount": 10000, + "currency": "EUR", + "creationDate": "2024-09-30T16:09:00+00:00", + "errorCode": null, + "errorMessage": null, + "detailedErrorCode": null, + "detailedErrorMessage": null, + "effectiveStrongAuthentication": "DISABLED", + "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": "johndoe@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": "103.105.136.19", + "browserUserAgent": null, + "_type": "V4/Customer/ExtraDetails" + }, + "shoppingCart": { + "insuranceAmount": null, + "shippingAmount": null, + "taxAmount": null, + "cartItemInfo": null, + "_type": "V4/Customer/ShoppingCart" + }, + "_type": "V4/Customer/Customer" + }, + "transactionDetails": { + "liabilityShift": null, + "effectiveAmount": 10000, + "effectiveCurrency": "EUR", + "creationContext": "REFUND", + "cardDetails": { + "paymentSource": "OTHER", + "manualValidation": "NO", + "expectedCaptureDate": "2024-09-30T16:09:00+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": "930244", + "legacyTransDate": "2024-09-30T16:09:00+00:00", + "paymentMethodSource": "NEW", + "authorizationResponse": { + "amount": null, + "currency": null, + "authorizationDate": null, + "authorizationNumber": null, + "authorizationResult": null, + "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": null, + "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": "OTHER", + "manualValidation": "NO", + "expectedCaptureDate": "2024-09-30T16:09:00+00:00", + "effectiveBrand": "VISA", + "expiryMonth": 12, + "expiryYear": 2025, + "country": "FR", + "issuerCode": null, + "issuerName": "Banque de demo et de l'innovation", + "effectiveProductCode": "F", + "legacyTransId": "930244", + "legacyTransDate": "2024-09-30T16:09:00+00:00", + "paymentMethodSource": "NEW", + "authorizationResponse": { + "amount": null, + "currency": null, + "authorizationDate": null, + "authorizationNumber": null, + "authorizationResult": null, + "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": null, + "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": [], + "_type": "V4/PaymentMethod/Details/FraudManagement" + }, + "parentTransactionUuid": "dbf4b89ae157499e83bea366c91daaa8", + "mid": "9876357", + "sequenceNumber": 1, + "taxAmount": null, + "preTaxAmount": null, + "taxRate": null, + "externalTransactionId": null, + "nsu": null, + "tid": "001", + "acquirerNetwork": "CB", + "taxRefundAmount": null, + "userInfo": null, + "paymentMethodTokenPreviouslyRegistered": null, + "occurrenceType": "UNITAIRE", + "archivalReferenceId": "L27493024401", + "useCase": null, + "wallet": null, + "_type": "V4/TransactionDetails" + }, + "orderDetails": { + "orderTotalAmount": 10000, + "orderEffectiveAmount": null, + "orderCurrency": "EUR", + "mode": "TEST", + "orderId": "myOrderId-948135", + "metadata": null, + "_type": "V4/OrderDetails" + }, + "operationType": "CREDIT", + "metadata": { + "installment_id": "d9356dd7-19a6-4695-b18e-ad93af41424a" + }, + "_type": "V4/Transaction" + }, + "ticket": "ffd839c87d5947c9a84262ddda3e2055", + "serverDate": "2024-09-30T16:09:01+00:00", + "applicationProvider": "LYRA", + "metadata": null, + "mode": "TEST", + "serverUrl": "https://api.lyra.com", + "_type": "V4/WebService/Response" +} diff --git a/src/backend/joanie/tests/payment/test_backend_base.py b/src/backend/joanie/tests/payment/test_backend_base.py index 7e0ff38c3..598f437cd 100644 --- a/src/backend/joanie/tests/payment/test_backend_base.py +++ b/src/backend/joanie/tests/payment/test_backend_base.py @@ -39,9 +39,22 @@ def call_do_on_payment_failure(self, order, installment_id=None): """call private method _do_on_payment_failure""" self._do_on_payment_failure(order, installment_id=installment_id) - def call_do_on_refund(self, amount, invoice, refund_reference): + def call_do_on_refund( + self, + amount, + invoice, + refund_reference, + installment_id, + is_transaction_canceled=False, + ): # pylint: disable=too-many-arguments, unused-argument """call private method _do_on_refund""" - self._do_on_refund(amount, invoice, refund_reference) + self._do_on_refund( + amount, + invoice, + refund_reference, + installment_id, + is_transaction_canceled=False, + ) def abort_payment(self, payment_id): pass @@ -66,6 +79,9 @@ def handle_notification(self, request): def tokenize_card(self, order=None, billing_address=None, user=None): pass + def cancel_or_refund(self, amount, transaction_reference): + pass + # pylint: disable=too-many-public-methods, too-many-lines @override_settings(JOANIE_CATALOG_NAME="Test Catalog") @@ -172,6 +188,18 @@ def test_payment_backend_base_tokenize_card_not_implemented(self): "subclasses of BasePaymentBackend must provide a tokenize_card() method.", ) + def test_payment_backend_base_cancel_or_refund(self): + """Invoke cancel or refund a transaction should raise a Not ImplementedError""" + backend = BasePaymentBackend() + + with self.assertRaises(NotImplementedError) as context: + backend.cancel_or_refund(None, None) + + self.assertEqual( + str(context.exception), + "subclasses of BasePaymentBackend must provide a cancel_or_refund() method.", + ) + def test_payment_backend_base_do_on_payment_success(self): """ Base backend contains a method _do_on_payment_success which aims to be @@ -536,20 +564,27 @@ def test_payment_backend_base_do_on_payment_failure_with_installment(self): def test_payment_backend_base_do_on_refund(self): """ - Base backend contains a method _do_on_refund which aims to be + Base backend contains a method `_do_on_refund` which aims to be call by subclasses when a refund occurred. It should register the refund transaction. """ backend = TestBasePaymentBackend() order = OrderFactory( + product__price=1000, payment_schedule=[ { "id": "d9356dd7-19a6-4695-b18e-ad93af41424a", - "amount": "200.00", + "amount": "300.00", "due_date": "2024-01-17", "state": enums.PAYMENT_STATE_PENDING, }, - ] + { + "id": "36981c13-1a1d-4f20-8f8f-e8a9e3ecb6cf", + "amount": "700.00", + "due_date": "2024-02-17", + "state": enums.PAYMENT_STATE_PENDING, + }, + ], ) billing_address = BillingAddressDictFactory() CreditCardFactory( @@ -560,7 +595,7 @@ def test_payment_backend_base_do_on_refund(self): # Create payment and register it payment = { "id": "pay_0", - "amount": order.total, + "amount": Decimal(str(order.payment_schedule[0]["amount"])), "billing_address": billing_address, "installment_id": "d9356dd7-19a6-4695-b18e-ad93af41424a", } @@ -568,22 +603,34 @@ def test_payment_backend_base_do_on_refund(self): backend.call_do_on_payment_success(order, payment) Transaction.objects.get(reference="pay_0") - # - Order has been completed - self.assertEqual(order.state, enums.ORDER_STATE_COMPLETED) - - # - Refund entirely the order + # - Order should be in state `pending_payment` since 1 or 2 installment has been paid + self.assertEqual(order.state, enums.ORDER_STATE_PENDING_PAYMENT) + order.flow.cancel() + self.assertEqual(order.state, enums.ORDER_STATE_CANCELED) + # - Refund the paid installment of the order in the payment schedule backend.call_do_on_refund( - amount=order.total, + amount=Decimal(str(order.payment_schedule[0]["amount"])), invoice=order.main_invoice, refund_reference="ref_0", + installment_id=payment["installment_id"], + is_transaction_canceled=False, ) - - # - Credit transaction has been created + # - Credit transaction has been created and a credit note self.assertEqual( - Transaction.objects.filter(reference="ref_0", total=-order.total).count(), + Transaction.objects.filter( + reference="ref_0", + total=-Decimal(str(order.payment_schedule[0]["amount"])), + ).count(), 1, ) + transaction = Transaction.objects.get( + reference="ref_0", + total=-Decimal(str(order.payment_schedule[0]["amount"])), + ) + + self.assertEqual(transaction.invoice.type, "credit_note") + # - Order has been canceled order.refresh_from_db() self.assertEqual(order.state, "canceled") 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 0ee081c5f..4d5da2941 100644 --- a/src/backend/joanie/tests/payment/test_backend_dummy_payment.py +++ b/src/backend/joanie/tests/payment/test_backend_dummy_payment.py @@ -13,12 +13,14 @@ from rest_framework.test import APIRequestFactory from joanie.core.enums import ( + ORDER_STATE_CANCELED, ORDER_STATE_COMPLETED, ORDER_STATE_NO_PAYMENT, ORDER_STATE_PENDING, ORDER_STATE_PENDING_PAYMENT, PAYMENT_STATE_PAID, PAYMENT_STATE_PENDING, + PAYMENT_STATE_REFUNDED, ) from joanie.core.factories import ( OrderFactory, @@ -33,7 +35,7 @@ RefundPaymentFailed, RegisterPaymentFailed, ) -from joanie.payment.models import CreditCard +from joanie.payment.models import CreditCard, Transaction from joanie.tests.payment.base_payment import BasePaymentTestCase @@ -671,9 +673,10 @@ def test_payment_backend_dummy_handle_notification_refund(self, mock_refund): mock_refund.assert_called_once() args = mock_refund.call_args.kwargs - self.assertEqual(len(args), 3) + self.assertEqual(len(args), 4) self.assertEqual(float(args["amount"]), float(first_installment["amount"])) self.assertEqual(args["invoice"], order.main_invoice) + self.assertEqual(args["installment_id"], str(first_installment["id"])) self.assertIsNotNone(re.fullmatch(r"ref_\d{10}", args["refund_reference"])) def test_payment_backend_dummy_abort_payment_with_unknown_payment_id(self): @@ -790,3 +793,133 @@ def test_payment_backend_dummy_handle_notification_payment_failed_should_send_ma mock_logger.assert_called_with( "Mail is sent to %s from dummy payment", order.owner.email ) + + def test_payment_backend_dummy_cancel_or_refund(self): + """ + Cancel/Refund a transaction should return True if it finds the created payment + in the cache. If it is found, it proceeds to refund the installment. + """ + backend = DummyPaymentBackend() + request_factory = APIRequestFactory() + order = OrderGeneratorFactory(state=ORDER_STATE_PENDING, product__price=1000) + # Create a payment + payment_id = backend.create_payment( + order=order, + installment=order.payment_schedule[0], + billing_address=order.main_invoice.recipient_address.to_dict(), + )["payment_id"] + # Notify that payment has been paid + request = request_factory.post( + reverse("payment_webhook"), + data={ + "id": payment_id, + "type": "payment", + "state": "success", + "installment_id": order.payment_schedule[0]["id"], + }, + format="json", + ) + request.data = json.loads(request.body.decode("utf-8")) + backend.handle_notification(request) + + # The installment's state should be in state 'paid + order.refresh_from_db() + self.assertEqual(order.payment_schedule[0]["state"], PAYMENT_STATE_PAID) + # Cancel the order to ask for a refund of the paid installment + order.flow.cancel() + order.refresh_from_db() + self.assertEqual(order.state, ORDER_STATE_CANCELED) + # Get the transaction of the paid installment + transaction = Transaction.objects.get(reference=payment_id) + + refund_response = backend.cancel_or_refund( + amount=order.payment_schedule[0]["amount"], + transaction_reference=transaction.reference, + ) + + # The installment must remain 'paid' until the handle_notification switches the + # state of the installment to 'refunded' + order.refresh_from_db() + self.assertEqual(order.payment_schedule[0]["state"], PAYMENT_STATE_PAID) + self.assertEqual(refund_response, True) + + # Notify that payment has been refunded + request = request_factory.post( + reverse("payment_webhook"), + data={ + "id": payment_id, + "type": "refund", + "amount": order.payment_schedule[0]["amount"].sub_units, + "installment_id": order.payment_schedule[0]["id"], + }, + format="json", + ) + request.data = json.loads(request.body.decode("utf-8")) + backend.handle_notification(request) + + order.refresh_from_db() + self.assertEqual(order.payment_schedule[0]["state"], PAYMENT_STATE_REFUNDED) + + def test_payment_backend_dummy_cancel_or_refund_raise_refund_payment_failed_no_created_payment( + self, + ): + """ + When there is no payment created before, we cannot refund a transaction that + does not exist. It should raise a `RegisterPaymentFailed`. + """ + backend = DummyPaymentBackend() + order = OrderGeneratorFactory(state=ORDER_STATE_PENDING, product__price=1000) + + with self.assertRaises(RegisterPaymentFailed) as context: + backend.cancel_or_refund( + amount=order.payment_schedule[0]["amount"], + transaction_reference="fake_transaction_reference_id", + ) + + self.assertEqual( + str(context.exception), + "Resource fake_transaction_reference_id does not exist, cannot refund.", + ) + + def test_payment_backend_dummy_cancel_or_refund_refund_payment_failed_because_amount_not_match( + self, + ): + """ + When we request a refund but the amount does not correspond to the transaction amount, + it raises a `RefundPaymentFailed` error. + """ + backend = DummyPaymentBackend() + request_factory = APIRequestFactory() + order = OrderGeneratorFactory(state=ORDER_STATE_PENDING, product__price=1000) + # Create a payment + payment_id = backend.create_payment( + order=order, + installment=order.payment_schedule[0], + billing_address=order.main_invoice.recipient_address.to_dict(), + )["payment_id"] + # Notify that payment has been paid + request = request_factory.post( + reverse("payment_webhook"), + data={ + "id": payment_id, + "type": "payment", + "state": "success", + "installment_id": order.payment_schedule[0]["id"], + }, + format="json", + ) + request.data = json.loads(request.body.decode("utf-8")) + backend.handle_notification(request) + + transaction = Transaction.objects.get(reference=payment_id) + + with self.assertRaises(RefundPaymentFailed) as context: + backend.cancel_or_refund( + amount=D("20.00"), + transaction_reference=transaction.reference, + ) + + self.assertEqual( + str(context.exception), + f"Resource {transaction.reference} amount does not match the amount to refund", + ) diff --git a/src/backend/joanie/tests/payment/test_backend_lyra.py b/src/backend/joanie/tests/payment/test_backend_lyra.py index 67ed4d4e7..a703c3d9d 100644 --- a/src/backend/joanie/tests/payment/test_backend_lyra.py +++ b/src/backend/joanie/tests/payment/test_backend_lyra.py @@ -15,10 +15,12 @@ from rest_framework.test import APIRequestFactory from joanie.core.enums import ( + ORDER_STATE_CANCELED, ORDER_STATE_COMPLETED, ORDER_STATE_PENDING, ORDER_STATE_PENDING_PAYMENT, PAYMENT_STATE_PAID, + PAYMENT_STATE_REFUNDED, ) from joanie.core.factories import ( OrderFactory, @@ -35,7 +37,11 @@ RegisterPaymentFailed, TokenizationCardFailed, ) -from joanie.payment.factories import CreditCardFactory +from joanie.payment.factories import ( + CreditCardFactory, + InvoiceFactory, + TransactionFactory, +) from joanie.payment.models import CreditCard, Transaction from joanie.tests.base import BaseLogMixinTestCase from joanie.tests.payment.base_payment import BasePaymentTestCase @@ -1715,3 +1721,314 @@ def test_payment_backend_lyra_payment_failure_send_mail_use_fallback_language_tr mail.outbox[0].subject, ) self.assertIn("Product 1", email_content) + + @override_settings(JOANIE_PAYMENT_SCHEDULE_LIMITS={100: (30, 35, 35)}) + @responses.activate(assert_all_requests_are_fired=True) + def test_backend_lyra_refund_transaction(self): + """ + When backend requests a refund of a transaction, it should return True if + the action was successful. + """ + backend = LyraBackend(self.configuration) + owner = UserFactory(email="john.doe@acme.org") + order = OrderGeneratorFactory( + owner=owner, product__price=100, state=ORDER_STATE_PENDING_PAYMENT + ) + # Set manually the id of the installment + order.payment_schedule[0]["id"] = "d9356dd7-19a6-4695-b18e-ad93af41424a" + child_invoice = InvoiceFactory( + order=order, + total=0, + parent=order.main_invoice, + recipient_address=order.main_invoice.recipient_address, + ) + transaction = TransactionFactory( + total=D(str(order.payment_schedule[0]["amount"])), + invoice=child_invoice, + reference="dbf4b89ae157499e83bea366c91daaa8", # Transaction uuid from payment provider + ) + order.flow.cancel() + + with self.open("lyra/responses/refund_transaction_payment.json") as file: + json_response = json.loads(file.read()) + + responses.add( + responses.POST, + "https://api.lyra.com/api-payment/V4/Transaction/CancelOrRefund", + headers={ + "Content-Type": "application/json", + }, + match=[ + responses.matchers.header_matcher( + { + "content-type": "application/json", + "authorization": "Basic Njk4NzYzNTc6dGVzdHBhc3N3b3JkX0RFTU9QUklWQVRFS0VZMjNHNDQ3NXpYWlEyVUE1eDdN", + } + ), + responses.matchers.json_params_matcher( + { + "amount": 3000, + "currency": "EUR", + "uuid": "dbf4b89ae157499e83bea366c91daaa8", + "resolutionMode": "AUTO", + }, + ), + ], + status=200, + json=json_response, + ) + + response = backend.cancel_or_refund( + amount=order.payment_schedule[0]["amount"], + transaction_reference=transaction.reference, + ) + + self.assertEqual(response, True) + + @override_settings(JOANIE_PAYMENT_SCHEDULE_LIMITS={100: (30, 35, 35)}) + @responses.activate(assert_all_requests_are_fired=True) + def test_backend_lyra_cancel_transaction(self): + """ + When backend requests a refund of a transaction that will be canceled on the payment + provider side, it should return True if the action was successful. + """ + backend = LyraBackend(self.configuration) + owner = UserFactory(email="john.doe@acme.org") + order = OrderGeneratorFactory( + owner=owner, product__price=100, state=ORDER_STATE_PENDING_PAYMENT + ) + # Set manually the id of the 1st installment + order.payment_schedule[0]["id"] = "d9356dd7-19a6-4695-b18e-ad93af41424a" + child_invoice = InvoiceFactory( + order=order, + total=0, + parent=order.main_invoice, + recipient_address=order.main_invoice.recipient_address, + ) + transaction = TransactionFactory( + total=D(str(order.payment_schedule[0]["amount"])), + invoice=child_invoice, + reference="d1053bae1aad463f8975ec248fa46eb3", # Transaction uuid from payment provider + ) + order.flow.cancel() + + with self.open("lyra/responses/cancel_transaction_payment.json") as file: + json_response = json.loads(file.read()) + + responses.add( + responses.POST, + "https://api.lyra.com/api-payment/V4/Transaction/CancelOrRefund", + headers={ + "Content-Type": "application/json", + }, + match=[ + responses.matchers.header_matcher( + { + "content-type": "application/json", + "authorization": "Basic Njk4NzYzNTc6dGVzdHBhc3N3b3JkX0RFTU9QUklWQVRFS0VZMjNHNDQ3NXpYWlEyVUE1eDdN", + } + ), + responses.matchers.json_params_matcher( + { + "amount": 3000, + "currency": "EUR", + "uuid": "d1053bae1aad463f8975ec248fa46eb3", + "resolutionMode": "AUTO", + }, + ), + ], + status=200, + json=json_response, + ) + + response = backend.cancel_or_refund( + amount=order.payment_schedule[0]["amount"], + transaction_reference=transaction.reference, + ) + + self.assertEqual(response, True) + + @override_settings(JOANIE_PAYMENT_SCHEDULE_LIMITS={100: (30, 35, 35)}) + @responses.activate(assert_all_requests_are_fired=True) + def test_payment_backend_lyra_cancel_or_refund_with_wrong_transaction_reference_id( + self, + ): + """ + When we request a refund/cancel of a transaction with the payment provider + with a transaction reference that does not exist on the payment provider side, + we should get in return the value False because it was not successful. + """ + backend = LyraBackend(self.configuration) + owner = UserFactory(email="john.doe@acme.org") + order = OrderGeneratorFactory( + owner=owner, product__price=1000, state=ORDER_STATE_PENDING_PAYMENT + ) + order.flow.cancel() + + with self.open("lyra/responses/cancel_and_refund_failed.json") as file: + json_response = json.loads(file.read()) + # Make on purpose a fake transaction id directly without creating the transaction... + responses.add( + responses.POST, + "https://api.lyra.com/api-payment/V4/Transaction/CancelOrRefund", + headers={ + "Content-Type": "application/json", + }, + match=[ + responses.matchers.header_matcher( + { + "content-type": "application/json", + "authorization": "Basic Njk4NzYzNTc6dGVzdHBhc3N3b3JkX0RFTU9QUklWQVRFS0VZMjNHNDQ3NXpYWlEyVUE1eDdN", + } + ), + responses.matchers.json_params_matcher( + { + "amount": 30000, + "currency": "EUR", + "uuid": "wrong_transaction_id", + "resolutionMode": "AUTO", + }, + ), + ], + status=200, + json=json_response, + ) + + with ( + self.assertRaises(PaymentProviderAPIException) as context, + self.assertLogs() as logger, + ): + backend.cancel_or_refund( + order.payment_schedule[0]["amount"], "wrong_transaction_id" + ) + + self.assertEqual( + str(context.exception), + "Error when calling Lyra API - PSP_010 : transaction not found.", + ) + + expected_logs = [ + ( + "INFO", + "Calling Lyra API https://api.lyra.com/api-payment/V4/Transaction/CancelOrRefund", + { + "url": str, + "headers": dict, + "payload": dict, + }, + ), + ( + "ERROR", + "Error calling Lyra API https://api.lyra.com/api-payment/V4/Transaction/CancelOrRefund" + " | PSP_010: transaction not found", + { + "url": str, + "headers": dict, + "payload": dict, + "response_json": dict, + }, + ), + ] + self.assertLogsEquals(logger.records, expected_logs) + + @override_settings(JOANIE_PAYMENT_SCHEDULE_LIMITS={0: (100,)}) + def test_payment_backend_lyra_handle_notification_refund_transaction(self): + """ + When backend receives a refund payment notification, it should create a credit note, + a transaction reflecting the refund and also, it should set the installment has + 'refunded' and the order's state as 'canceled'. + """ + backend = LyraBackend(self.configuration) + user = UserFactory(email="john.doe@acme.org") + order = OrderGeneratorFactory( + state=ORDER_STATE_PENDING, + id="a7834082-a000-4de4-af6e-e09683c9a752", + 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" + first_installment["state"] = PAYMENT_STATE_PAID + TransactionFactory.create( + reference="dbf4b89a-e157-499e-83be-a366c91daaa8", + invoice__order=order, + invoice__parent=order.main_invoice, + invoice__total=0, + invoice__recipient_address__owner=order.owner, + total=str(order.payment_schedule[0]["amount"]), + ) + order.flow.cancel() + + with self.open("lyra/requests/refund_accepted_transaction.json") as file: + json_request = json.loads(file.read()) + + request = APIRequestFactory().post( + reverse("payment_webhook"), data=json_request, format="multipart" + ) + + backend.handle_notification(request) + + order.refresh_from_db() + refund_transaction = Transaction.objects.get( + reference="50369f1f6c3f4ea6a451a41662688133" + ) + credit_note = refund_transaction.invoice + self.assertEqual(refund_transaction.total, -D("123.45")) + self.assertEqual(refund_transaction.total, credit_note.total) + self.assertEqual( + refund_transaction.invoice.order.main_invoice, order.main_invoice + ) + self.assertEqual(order.payment_schedule[0]["state"], PAYMENT_STATE_REFUNDED) + self.assertEqual(order.state, ORDER_STATE_CANCELED) + + @override_settings(JOANIE_PAYMENT_SCHEDULE_LIMITS={0: (100,)}) + def test_payment_backend_lyra_handle_notification_cancel_transaction(self): + """ + When backend receives a cancellation notification, it should create a credit note, + a transaction reflecting the refund and also, it should set the installment has + 'refunded' and the order's state as 'canceled'. + """ + backend = LyraBackend(self.configuration) + user = UserFactory(email="john.doe@acme.org") + order = OrderGeneratorFactory( + state=ORDER_STATE_PENDING, + id="a7834082-a000-4de4-af6e-e09683c9a752", + 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" + first_installment["state"] = PAYMENT_STATE_PAID + TransactionFactory.create( + reference="d1053bae1aad463f8975ec248fa46eb3", + invoice__order=order, + invoice__parent=order.main_invoice, + invoice__total=0, + invoice__recipient_address__owner=order.owner, + total=str(order.payment_schedule[0]["amount"]), + ) + order.flow.cancel() + + with self.open("lyra/requests/cancel_transaction.json") as file: + json_request = json.loads(file.read()) + + request = APIRequestFactory().post( + reverse("payment_webhook"), data=json_request, format="multipart" + ) + + backend.handle_notification(request) + + order.refresh_from_db() + cancel_transaction = Transaction.objects.get( + reference="cancel_d1053bae1aad463f8975ec248fa46eb3" + ) + credit_note = cancel_transaction.invoice + self.assertEqual(cancel_transaction.total, -D("123.45")) + self.assertEqual(cancel_transaction.total, credit_note.total) + self.assertEqual( + cancel_transaction.invoice.order.main_invoice, order.main_invoice + ) + self.assertEqual(order.payment_schedule[0]["state"], PAYMENT_STATE_REFUNDED) + self.assertEqual(order.state, ORDER_STATE_CANCELED) diff --git a/src/backend/joanie/tests/payment/test_backend_payplug.py b/src/backend/joanie/tests/payment/test_backend_payplug.py index a15d4fe7a..e56f1572c 100644 --- a/src/backend/joanie/tests/payment/test_backend_payplug.py +++ b/src/backend/joanie/tests/payment/test_backend_payplug.py @@ -849,8 +849,31 @@ def test_payment_backend_payplug_handle_notification_refund( When backend receives a refund notification, it should call the generic method `_do_on_refund`. """ - order = OrderFactory() - payment = TransactionFactory(invoice__order=order) + order = OrderFactory( + state=enums.ORDER_STATE_PENDING_PAYMENT, + payment_schedule=[ + { + "id": "a45f5574-221a-420f-aac0-ebcc89a4c867", + "state": enums.PAYMENT_STATE_PAID, + "amount": "300.00", + "due_date": "2024-10-13", + }, + { + "id": "a45f5574-221a-420f-aac0-ebcc89a4c867", + "state": enums.PAYMENT_STATE_PENDING, + "amount": "700.00", + "due_date": "2024-11-13", + }, + ], + ) + payment = TransactionFactory( + invoice__order=order, + invoice__parent=order.main_invoice, + invoice__total=0, + invoice__recipient_address__owner=order.owner, + total=str(order.payment_schedule[0]["amount"]), + reference=str(order.payment_schedule[0]["id"]), + ) mock_treat.return_value = PayplugFactories.PayplugRefundFactory( payment_id=payment.reference @@ -864,9 +887,11 @@ def test_payment_backend_payplug_handle_notification_refund( mock_do_on_refund.assert_called_once() args = mock_do_on_refund.call_args.kwargs - self.assertEqual(len(args), 3) + self.assertEqual(len(args), 5) self.assertIsInstance(args["amount"], D) self.assertEqual(args["invoice"], payment.invoice) + self.assertEqual(args["installment_id"], order.payment_schedule[0]["id"]) + self.assertEqual(args["is_transaction_canceled"], False) self.assertIsNotNone(re.fullmatch(r"ref_\d{5}", args["refund_reference"])) @mock.patch.object(payplug.Payment, "abort") 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",