diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5de97c5d5..cab568a1b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -41,6 +41,7 @@ jobs: run: | touch .env echo "AZURE_STORAGE_CONNECTION_STRING=${{ secrets.AZURE_STORAGE_CONNECTION_STRING }}" >> .env + echo "VIPPS_MERCHANT_SERIAL_NUMBER=${{ secrets.VIPPS_MERCHANT_SERIAL_NUMBER }}" >> .env - name: Build the Stack run: docker-compose build diff --git a/app/content/exceptions.py b/app/content/exceptions.py index 07eae9f42..c3af02d3b 100644 --- a/app/content/exceptions.py +++ b/app/content/exceptions.py @@ -4,7 +4,12 @@ class APIPaidEventCantBeChangedToFreeEventException(APIException): status_code = status.HTTP_400_BAD_REQUEST - default_detail = "Arrangementet er et betalt arrangement, og kan ikke endres til et gratis arrangement" + default_detail = "Arrangementet er et betalt arrangement med påmeldte deltagere, og kan ikke endres til et gratis arrangement" + + +class APIEventCantBeChangedToPaidEventException(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = "Arrangementet er et gratis arrangement med påmeldte deltagere, og kan ikke endres til et betalt arrangement" class APIUserAlreadyAttendedEvent(APIException): @@ -48,3 +53,7 @@ class UnansweredFormError(ValueError): class EventIsFullError(ValueError): pass + + +class RefundFailedError(ValueError): + pass diff --git a/app/content/migrations/0054_registration_payment_expiredate.py b/app/content/migrations/0054_registration_payment_expiredate.py new file mode 100644 index 000000000..9df7b112f --- /dev/null +++ b/app/content/migrations/0054_registration_payment_expiredate.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.5 on 2023-10-18 08:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("content", "0053_event_contact_person"), + ] + + operations = [ + migrations.AddField( + model_name="registration", + name="payment_expiredate", + field=models.DateTimeField(default=None, null=True), + ), + ] diff --git a/app/content/migrations/0058_merge_20231217_2155.py b/app/content/migrations/0058_merge_20231217_2155.py new file mode 100644 index 000000000..16c3bfd0f --- /dev/null +++ b/app/content/migrations/0058_merge_20231217_2155.py @@ -0,0 +1,13 @@ +# Generated by Django 4.2.5 on 2023-12-17 20:55 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("content", "0054_registration_payment_expiredate"), + ("content", "0057_event_emojis_allowed_news_emojis_allowed"), + ] + + operations = [] diff --git a/app/content/models/event.py b/app/content/models/event.py index ffe9884b7..26325aa0b 100644 --- a/app/content/models/event.py +++ b/app/content/models/event.py @@ -108,6 +108,11 @@ def list_count(self): """Number of users registered to attend the event""" return self.get_participants().count() + @property + def has_participants(self): + """Returns if the event has users registered to attend the event""" + return self.list_count > 0 + @property def waiting_list_count(self): """Number of users on the waiting list""" diff --git a/app/content/models/registration.py b/app/content/models/registration.py index 544ec5979..1ccda13c4 100644 --- a/app/content/models/registration.py +++ b/app/content/models/registration.py @@ -4,6 +4,8 @@ from django.db import models from django.db.models import Q +from sentry_sdk import capture_exception + from app.common.enums import StrikeEnum from app.common.permissions import BasePermissionModel from app.communication.enums import UserNotificationSettingType @@ -17,7 +19,9 @@ from app.content.models.event import Event from app.content.models.strike import create_strike from app.content.models.user import User +from app.content.util.registration_utils import get_payment_expiredate from app.forms.enums import EventFormType +from app.payment.util.order_utils import check_if_order_is_paid, has_paid_order from app.util import now from app.util.models import BaseModel from app.util.utils import datetime_format @@ -36,6 +40,7 @@ class Registration(BaseModel, BasePermissionModel): is_on_wait = models.BooleanField(default=False, verbose_name="waiting list") has_attended = models.BooleanField(default=False) allow_photo = models.BooleanField(default=True) + payment_expiredate = models.DateTimeField(null=True, default=None) created_by_admin = models.BooleanField(default=False) class Meta: @@ -86,7 +91,27 @@ def delete_submission_if_exists(self): )[:1] Submission.objects.filter(form=event_form, user=self.user).delete() + def refund_payment_if_exist(self): + from app.content.util.event_utils import refund_vipps_order + + if not self.event.is_paid_event: + return + + orders = self.event.orders.filter(user=self.user) + + if has_paid_order(orders): + for order in orders: + if check_if_order_is_paid(order): + refund_vipps_order( + order_id=order.order_id, + event=self.event, + transaction_text=f"Refund for {self.event.title} - {self.user.first_name} {self.user.last_name}", + ) + self.send_notification_and_mail_for_refund(order) + def delete(self, *args, **kwargs): + from app.content.util.event_utils import start_payment_countdown + moved_registration = None if not self.is_on_wait: if self.event.is_past_sign_off_deadline: @@ -99,9 +124,26 @@ def delete(self, *args, **kwargs): moved_registration = self.move_from_waiting_list_to_queue() self.delete_submission_if_exists() + + # TODO: Add this for refund + # self.refund_payment_if_exist() + registration = super().delete(*args, **kwargs) if moved_registration: moved_registration.save() + + if ( + moved_registration.event.is_paid_event + and not moved_registration.is_on_wait + ): + try: + start_payment_countdown( + moved_registration.event, moved_registration + ) + except Exception as countdown_error: + capture_exception(countdown_error) + moved_registration.delete() + return registration def admin_unregister(self, *args, **kwargs): @@ -115,6 +157,7 @@ def admin_unregister(self, *args, **kwargs): moved_registration.save() def save(self, *args, **kwargs): + if not self.registration_id: self.create() @@ -208,6 +251,19 @@ def send_notification_and_mail(self): self.event.pk ).send() + def send_notification_and_mail_for_refund(self, order): + Notify( + [self.user], + f'Du har blitt meldt av "{self.event.title}" og vil bli refundert', + UserNotificationSettingType.UNREGISTRATION, + ).add_paragraph(f"Hei, {self.user.first_name}!").add_paragraph( + f"Du har blitt meldt av {self.event.title} og vil bli refundert." + ).add_paragraph( + "Du vil få pengene tilbake på kontoen din innen 2 til 3 virkedager. I enkelte tilfeller, avhengig av bank, tar det inntil 10 virkedager." + ).add_paragraph( + f"Hvis det skulle oppstå noen problemer så kontakt oss på hs@tihlde.org. Ditt ordrenummer er {order.order_id}." + ).send() + def should_swap_with_non_prioritized_user(self): return ( self.is_on_wait @@ -279,6 +335,12 @@ def move_from_waiting_list_to_queue(self): registrations_in_waiting_list[0], ) registration_move_to_queue.is_on_wait = False + + if self.event.is_paid_event: + registration_move_to_queue.payment_expiredate = get_payment_expiredate( + self.event + ) + return registration_move_to_queue def move_from_queue_to_waiting_list(self): diff --git a/app/content/serializers/event.py b/app/content/serializers/event.py index 0d2131e24..be994abbd 100644 --- a/app/content/serializers/event.py +++ b/app/content/serializers/event.py @@ -5,7 +5,10 @@ from app.common.enums import GroupType from app.common.serializers import BaseModelSerializer -from app.content.exceptions import APIPaidEventCantBeChangedToFreeEventException +from app.content.exceptions import ( + APIEventCantBeChangedToPaidEventException, + APIPaidEventCantBeChangedToFreeEventException, +) from app.content.models import Event, PriorityPool from app.content.serializers.priority_pool import ( PriorityPoolCreateSerializer, @@ -176,36 +179,57 @@ def update(self, instance, validated_data): priority_pools_data = validated_data.pop("priority_pools", None) paid_information_data = validated_data.pop("paid_information", None) limit = validated_data.get("limit") - limit_difference = 0 - if limit: - limit_difference = limit - instance.limit + instance_limit = instance.limit event = super().update(instance, validated_data) + self.update_queue(event, limit, instance_limit) + + self.update_from_free_to_paid(event, paid_information_data) + + self.update_from_paid_to_free(event, paid_information_data) + + if len(paid_information_data): + self.update_paid_information(event, paid_information_data) + + if priority_pools_data: + self.update_priority_pools(event, priority_pools_data) + + event.save() + return event + + def update_queue(self, event, limit, instance_limit): + if not limit: + return + + limit_difference = limit - instance_limit + if limit_difference > 0 and event.waiting_list_count > 0: event.move_users_from_waiting_list_to_queue(limit_difference) if limit_difference < 0: event.move_users_from_queue_to_waiting_list(abs(limit_difference)) + def update_from_paid_to_free(self, event, paid_information_data): if paid_information_data and not event.is_paid_event: + if event.has_participants: + raise APIEventCantBeChangedToPaidEventException() + PaidEvent.objects.create( event=event, price=paid_information_data["price"], paytime=paid_information_data["paytime"], ) - if event.is_paid_event and not len(paid_information_data): - raise APIPaidEventCantBeChangedToFreeEventException() - - if len(paid_information_data): - self.update_paid_information(event, paid_information_data) - - if priority_pools_data: - self.update_priority_pools(event, priority_pools_data) + def update_from_free_to_paid(self, event, paid_information_data): + if event.is_paid_event: + if not len(paid_information_data) and event.has_participants: + raise APIPaidEventCantBeChangedToFreeEventException() - event.save() - return event + paid_event = PaidEvent.objects.filter(event=event) + if paid_event: + paid_event.first().delete() + event.paid_information = None def update_priority_pools(self, event, priority_pools_data): event.priority_pools.all().delete() diff --git a/app/content/serializers/registration.py b/app/content/serializers/registration.py index 61b6156c6..724c2c697 100644 --- a/app/content/serializers/registration.py +++ b/app/content/serializers/registration.py @@ -6,17 +6,18 @@ DefaultUserSerializer, UserListSerializer, ) +from app.content.util.registration_utils import get_payment_expiredate from app.forms.enums import EventFormType from app.forms.serializers.submission import SubmissionInRegistrationSerializer from app.payment.enums import OrderStatus -from app.payment.serializers.order import OrderSerializer +from app.payment.util.order_utils import has_paid_order +from app.payment.util.payment_utils import get_payment_order_status class RegistrationSerializer(BaseModelSerializer): user_info = UserListSerializer(source="user", read_only=True) survey_submission = serializers.SerializerMethodField() has_unanswered_evaluation = serializers.SerializerMethodField() - order = serializers.SerializerMethodField(required=False) has_paid_order = serializers.SerializerMethodField(required=False) wait_queue_number = serializers.SerializerMethodField(required=False) @@ -31,7 +32,7 @@ class Meta: "created_at", "survey_submission", "has_unanswered_evaluation", - "order", + "payment_expiredate", "has_paid_order", "wait_queue_number", "created_by_admin", @@ -44,20 +45,23 @@ def get_survey_submission(self, obj): def get_has_unanswered_evaluation(self, obj): return obj.user.has_unanswered_evaluations_for(obj.event) - def get_order(self, obj): - order = obj.event.orders.filter(user=obj.user).first() - if order: - return OrderSerializer(order).data - return None - def get_has_paid_order(self, obj): - for order in obj.event.orders.filter(user=obj.user): - if ( - order.status == OrderStatus.CAPTURE - or order.status == OrderStatus.RESERVE - or order.status == OrderStatus.SALE - ): - return True + orders = obj.event.orders.filter(user=obj.user) + + if orders and (order := orders.first()).status == OrderStatus.INITIATE: + order_status = get_payment_order_status(order.order_id) + order.status = order_status + order.save() + + return has_paid_order(orders) + + def create(self, validated_data): + event = validated_data["event"] + + if event.is_paid_event and not event.is_full: + validated_data["payment_expiredate"] = get_payment_expiredate(event) + + return super().create(validated_data) def get_wait_queue_number(self, obj): if obj.is_on_wait: diff --git a/app/content/tests/test_event_utils.py b/app/content/tests/test_event_utils.py new file mode 100644 index 000000000..ec8b3c925 --- /dev/null +++ b/app/content/tests/test_event_utils.py @@ -0,0 +1,39 @@ +from datetime import timedelta + +import pytest + +from app.content.factories import EventFactory, RegistrationFactory +from app.content.util.event_utils import get_countdown_time +from app.payment.factories import PaidEventFactory + + +@pytest.fixture() +def paid_event(): + return PaidEventFactory() + + +@pytest.fixture() +def event(): + return EventFactory() + + +@pytest.fixture() +def registration(paid_event): + return RegistrationFactory(event=paid_event) + + +@pytest.mark.django_db +def test_that_paytime_countdown_adds_ten_minutes(paid_event): + """ + Should return the countdown time of the event + 10 minutes. + """ + + paytime = paid_event.paytime + paytime_in_seconds = (paytime.hour * 60 + paytime.minute) * 60 + paytime.second + + countdown_time = get_countdown_time(paid_event.event) + + ten_minutes = timedelta(minutes=10) + ten_minutes_in_seconds = ten_minutes.seconds + + assert countdown_time - paytime_in_seconds == ten_minutes_in_seconds diff --git a/app/content/util/event_utils.py b/app/content/util/event_utils.py index 034b98cc4..30852acdf 100644 --- a/app/content/util/event_utils.py +++ b/app/content/util/event_utils.py @@ -1,66 +1,89 @@ import os -import uuid -from datetime import datetime, timedelta +from datetime import datetime -from app.payment.enums import OrderStatus -from app.payment.models.order import Order +from sentry_sdk import capture_exception + +from app.content.exceptions import RefundFailedError from app.payment.tasks import check_if_has_paid from app.payment.util.payment_utils import ( get_new_access_token, initiate_payment, + refund_payment, ) -def create_payment_order(event, request, registration): +def start_payment_countdown(event, registration): """ Checks if event is a paid event - and creates a new Vipps payment order. + and starts the countdown for payment for an user. + """ + + if not event.is_paid_event or registration.is_on_wait: + return + + try: + check_if_has_paid.apply_async( + args=(event.id, registration.registration_id), + countdown=get_countdown_time(event), + ) + except Exception as payment_countdown_error: + capture_exception(payment_countdown_error) + + +def get_countdown_time(event): + paytime = event.paid_information.paytime + return (paytime.hour * 60 + paytime.minute + 10) * 60 + paytime.second + + +def create_vipps_order(order_id, event, transaction_text, fallback): + """ + Creates vipps order, and returns the url. + """ + + access_token = os.environ.get("PAYMENT_ACCESS_TOKEN") + expires_at = os.environ.get("PAYMENT_ACCESS_TOKEN_EXPIRES_AT") + + if not access_token or datetime.now() >= datetime.fromtimestamp(int(expires_at)): + (expires_at, access_token) = get_new_access_token() + os.environ.update({"PAYMENT_ACCESS_TOKEN": access_token}) + os.environ.update({"PAYMENT_ACCESS_TOKEN_EXPIRES_AT": str(expires_at)}) + + event_price = int(event.paid_information.price * 100) + + response = initiate_payment( + amount=event_price, + order_id=str(order_id), + access_token=access_token, + transaction_text=transaction_text, + fallback=fallback, + ) + + return response["url"] + + +def refund_vipps_order(order_id, event, transaction_text): """ + Refunds vipps order. + """ + + access_token = os.environ.get("PAYMENT_ACCESS_TOKEN") + expires_at = os.environ.get("PAYMENT_ACCESS_TOKEN_EXPIRES_AT") + + if not access_token or datetime.now() >= datetime.fromtimestamp(int(expires_at)): + (expires_at, access_token) = get_new_access_token() + os.environ.update({"PAYMENT_ACCESS_TOKEN": access_token}) + os.environ.update({"PAYMENT_ACCESS_TOKEN_EXPIRES_AT": str(expires_at)}) + + event_price = int(event.paid_information.price) * 100 + + try: + refund_payment( + amount=event_price, + order_id=str(order_id), + access_token=access_token, + transaction_text=transaction_text, + ) - if event.is_paid_event: - access_token = os.environ.get("PAYMENT_ACCESS_TOKEN") - expires_at = os.environ.get("PAYMENT_ACCESS_TOKEN_EXPIRES_AT") - if not access_token or datetime.now() >= datetime.fromtimestamp( - int(expires_at) - ): - (expires_at, access_token) = get_new_access_token() - os.environ.update({"PAYMENT_ACCESS_TOKEN": access_token}) - os.environ.update({"PAYMENT_ACCESS_TOKEN_EXPIRES_AT": str(expires_at)}) - - prev_orders = Order.objects.filter(event=event, user=request.user) - has_paid_order = False - - for order in prev_orders: - if ( - order.status == OrderStatus.CAPTURE - or order.status == OrderStatus.RESERVE - or order.status == OrderStatus.SALE - ): - has_paid_order = True - break - - if not has_paid_order: - - paytime = event.paid_information.paytime - - expire_date = datetime.now() + timedelta( - hours=paytime.hour, minutes=paytime.minute, seconds=paytime.second - ) - - # Create Order - order_id = uuid.uuid4() - amount = int(event.paid_information.price * 100) - res = initiate_payment(amount, str(order_id), event.title, access_token) - payment_link = res["url"] - order = Order.objects.create( - order_id=order_id, - user=request.user, - event=event, - payment_link=payment_link, - expire_date=expire_date, - ) - order.save() - check_if_has_paid.apply_async( - args=(order.order_id, registration.registration_id), - countdown=(paytime.hour * 60 + paytime.minute) * 60 + paytime.second, - ) + except Exception as refund_error: + capture_exception(refund_error) + raise RefundFailedError("Tilbakebetaling feilet") diff --git a/app/content/util/registration_utils.py b/app/content/util/registration_utils.py new file mode 100644 index 000000000..1e024c7b7 --- /dev/null +++ b/app/content/util/registration_utils.py @@ -0,0 +1,9 @@ +from datetime import datetime, timedelta + + +def get_payment_expiredate(event): + return datetime.now() + timedelta( + hours=event.paid_information.paytime.hour, + minutes=event.paid_information.paytime.minute, + seconds=event.paid_information.paytime.second, + ) diff --git a/app/content/views/event.py b/app/content/views/event.py index 55e3f6bcf..ecb522572 100644 --- a/app/content/views/event.py +++ b/app/content/views/event.py @@ -164,6 +164,7 @@ def create(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs): event = Event.objects.get(pk=kwargs["pk"]) if event.is_paid_event: + # TODO: Add refund for all paid orders by participants paid_event = PaidEvent.objects.get(event=kwargs["pk"]) paid_event.delete() diff --git a/app/content/views/registration.py b/app/content/views/registration.py index 0b1760c29..11bb2e6ca 100644 --- a/app/content/views/registration.py +++ b/app/content/views/registration.py @@ -1,5 +1,4 @@ import uuid -from datetime import datetime from django.shortcuts import get_object_or_404 from django_filters.rest_framework import DjangoFilterBackend @@ -22,9 +21,9 @@ from app.content.mixins import APIRegistrationErrorsMixin from app.content.models import Event, Registration, User from app.content.serializers import RegistrationSerializer -from app.content.util.event_utils import create_payment_order +from app.content.util.event_utils import start_payment_countdown from app.payment.enums import OrderStatus -from app.payment.models import Order +from app.payment.models.order import Order class RegistrationViewSet(APIRegistrationErrorsMixin, BaseViewSet): @@ -64,6 +63,7 @@ def create(self, request, *args, **kwargs): request.data["allow_photo"] = request.user.allows_photo_by_default serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) event_id = self.kwargs.get("event_id", None) @@ -74,13 +74,13 @@ def create(self, request, *args, **kwargs): ) try: - create_payment_order(event, request, registration) - except Exception as order_error: - capture_exception(order_error) + start_payment_countdown(event, registration) + except Exception as countdown_error: + capture_exception(countdown_error) registration.delete() return Response( { - "detail": "Det skjedde en feil med opprettelse av betalingsordre. Påmeldingen ble ikke fullført." + "detail": "Det skjedde en feil med oppstart av betalingsfrist. Påmeldingen ble ikke fullført." }, status=status.HTTP_400_BAD_REQUEST, ) @@ -173,7 +173,6 @@ def add_registration(self, request, *args, **kwargs): user=user, event=event, payment_link=f"https://tihlde.org/arrangementer/{event_id}/", - expire_date=datetime.now(), status=OrderStatus.SALE, ) except Exception as e: diff --git a/app/payment/admin.py b/app/payment/admin.py index 7e8d356e8..714568e5e 100644 --- a/app/payment/admin.py +++ b/app/payment/admin.py @@ -5,4 +5,10 @@ # Register your models here. admin.site.register(PaidEvent) -admin.site.register(Order) + + +@admin.register(Order) +class OrderAdmin(admin.ModelAdmin): + search_fields = ("user__first_name", "user__last_name", "user__user_id", "order_id") + + list_filter = ("event",) diff --git a/app/payment/enums.py b/app/payment/enums.py index 21cf044db..c319490b6 100644 --- a/app/payment/enums.py +++ b/app/payment/enums.py @@ -3,7 +3,7 @@ class OrderStatus(TextChoices): INITIATE = "INITIATE" - RESERVE = "RESERVE" + RESERVED = "RESERVED" CAPTURE = "CAPTURE" REFUND = "REFUND" CANCEL = "CANCEL" diff --git a/app/payment/factories/__init__.py b/app/payment/factories/__init__.py index e69de29bb..055d4ffe4 100644 --- a/app/payment/factories/__init__.py +++ b/app/payment/factories/__init__.py @@ -0,0 +1,2 @@ +from app.payment.factories.order_factory import OrderFactory +from app.payment.factories.paid_event_factory import PaidEventFactory diff --git a/app/payment/factories/order_factory.py b/app/payment/factories/order_factory.py index e48a65249..bc842fa0f 100644 --- a/app/payment/factories/order_factory.py +++ b/app/payment/factories/order_factory.py @@ -1,5 +1,4 @@ import random -from datetime import timedelta import factory from factory.django import DjangoModelFactory @@ -8,7 +7,6 @@ from app.content.factories.user_factory import UserFactory from app.payment.enums import OrderStatus from app.payment.models.order import Order -from app.util.utils import now class OrderFactory(DjangoModelFactory): @@ -18,4 +16,4 @@ class Meta: user = factory.SubFactory(UserFactory) event = factory.SubFactory(EventFactory) status = random.choice([e.value for e in OrderStatus]) - expire_date = now() + timedelta(hours=1) + payment_link = factory.Faker("url") diff --git a/app/payment/migrations/0002_remove_order_expire_date.py b/app/payment/migrations/0002_remove_order_expire_date.py new file mode 100644 index 000000000..3e7a176ca --- /dev/null +++ b/app/payment/migrations/0002_remove_order_expire_date.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.5 on 2023-10-18 09:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("payment", "0001_initial"), + ] + + operations = [ + migrations.RemoveField( + model_name="order", + name="expire_date", + ), + ] diff --git a/app/payment/migrations/0003_alter_order_status.py b/app/payment/migrations/0003_alter_order_status.py new file mode 100644 index 000000000..9b8a97f0e --- /dev/null +++ b/app/payment/migrations/0003_alter_order_status.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.5 on 2023-12-17 21:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("payment", "0002_remove_order_expire_date"), + ] + + operations = [ + migrations.AlterField( + model_name="order", + name="status", + field=models.CharField( + choices=[ + ("INITIATE", "Initiate"), + ("RESERVED", "Reserved"), + ("CAPTURE", "Capture"), + ("REFUND", "Refund"), + ("CANCEL", "Cancel"), + ("SALE", "Sale"), + ("VOID", "Void"), + ], + default="INITIATE", + max_length=16, + ), + ), + ] diff --git a/app/payment/models/order.py b/app/payment/models/order.py index 31e9381d2..85dbef834 100644 --- a/app/payment/models/order.py +++ b/app/payment/models/order.py @@ -8,7 +8,6 @@ from app.content.models.user import User from app.payment.enums import OrderStatus from app.util.models import BaseModel -from app.util.utils import now class Order(BaseModel, BasePermissionModel): @@ -25,16 +24,11 @@ class Order(BaseModel, BasePermissionModel): status = models.CharField( choices=OrderStatus.choices, default=OrderStatus.INITIATE, max_length=16 ) - expire_date = models.DateTimeField() payment_link = models.URLField(max_length=2000) class Meta: verbose_name_plural = "Orders" ordering = ("-created_at",) - def __str__(self): - return f"{self.order_id} {self.user} {self.event} {self.status} {self.expire_date}" - - @property - def expired(self): - return now() >= self.expire_date + def __str__(self): + return f"{self.user} - {self.event.title} - {self.status} - {self.created_at}" diff --git a/app/payment/serializers/__init__.py b/app/payment/serializers/__init__.py index 8789a8df3..76e890ea0 100644 --- a/app/payment/serializers/__init__.py +++ b/app/payment/serializers/__init__.py @@ -1 +1,5 @@ -from .order import OrderSerializer +from app.payment.serializers.order import ( + OrderSerializer, + OrderCreateSerializer, + VippsOrderSerialzer, +) diff --git a/app/payment/serializers/order.py b/app/payment/serializers/order.py index 51fd13a05..9e4e9f80d 100644 --- a/app/payment/serializers/order.py +++ b/app/payment/serializers/order.py @@ -1,31 +1,44 @@ +import uuid + from app.common.serializers import BaseModelSerializer -from app.content.models.event import Event -from app.content.models.user import User -from app.content.serializers.user import DefaultUserSerializer +from app.content.util.event_utils import create_vipps_order from app.payment.models.order import Order -from app.util.utils import now class OrderSerializer(BaseModelSerializer): class Meta: model = Order - fields = ("order_id", "status", "expire_date", "payment_link", "event", "user") + fields = ("order_id", "status", "payment_link") + +class VippsOrderSerialzer(BaseModelSerializer): + class Meta: + model = Order + fields = ("order_id",) -class OrderUpdateCreateSerializer(BaseModelSerializer): - user = DefaultUserSerializer(read_only=True) +class OrderCreateSerializer(BaseModelSerializer): class Meta: model = Order - fields = ("order_id", "user", "status", "expire_date") - - read_only_fields = "user" - - def create(self, validated_data): - user = User.objects.get(user_id=self.context["user_id"]) - paytime = Event.objects.get( - id=validated_data.get("event") - ).paid_information.paytime - return Order.objects.create( - user=user, expired_date=now() + paytime, **validated_data - ) + fields = ("event",) + + def create(self, validated_data): + user = validated_data.pop("user") + event = validated_data.pop("event") + + order_id = uuid.uuid4() + payment_url = create_vipps_order( + order_id=order_id, + event=event, + transaction_text=f"Betaling for {event.title} - {user.first_name} {user.last_name}", + fallback=f"/arrangementer/{event.id}", + ) + + order = Order.objects.create( + order_id=order_id, + payment_link=payment_url, + event=event, + user=user, + ) + + return order diff --git a/app/payment/tasks.py b/app/payment/tasks.py index 671ebf701..26e13fab9 100644 --- a/app/payment/tasks.py +++ b/app/payment/tasks.py @@ -1,25 +1,24 @@ -from sentry_sdk import capture_exception - from app.celery import app +from app.content.models.event import Event from app.content.models.registration import Registration -from app.payment.enums import OrderStatus from app.payment.models.order import Order -from app.payment.views.vipps_callback import vipps_callback +from app.payment.util.order_utils import has_paid_order from app.util.tasks import BaseTask @app.task(bind=True, base=BaseTask) -def check_if_has_paid(self, order_id, registration_id): - try: - vipps_callback(None, order_id) - order = Order.objects.get(order_id=order_id) - order_status = order.status - if ( - order_status != OrderStatus.CAPTURE - and order_status != OrderStatus.RESERVE - and order_status != OrderStatus.SALE - ): - Registration.objects.filter(registration_id=registration_id).delete() +def check_if_has_paid(self, event_id, registration_id): + registration = Registration.objects.filter(registration_id=registration_id).first() + event = Event.objects.filter(id=event_id).first() + + if not registration or not event: + return + + user_orders = Order.objects.filter(event=event, user=registration.user) + + if not user_orders: + registration.delete() + return - except Order.DoesNotExist as order_not_exist: - capture_exception(order_not_exist) + if not has_paid_order(user_orders): + registration.delete() diff --git a/app/payment/tests/test_order_model.py b/app/payment/tests/test_order_model.py deleted file mode 100644 index 12c4d6b83..000000000 --- a/app/payment/tests/test_order_model.py +++ /dev/null @@ -1,27 +0,0 @@ -from datetime import timedelta - -import pytest - -from app.payment.factories.order_factory import OrderFactory -from app.util.utils import now - - -@pytest.fixture() -def order(): - return OrderFactory() - - -@pytest.mark.django_db -def test_expired_when_order_has_not_expired(order): - """Should return False if the order has not expired""" - order.expire_date = now() + timedelta(hours=1) - - assert not order.expired - - -@pytest.mark.django_db -def test_expired_when_order_has_expired(order): - """Should return True if the order has expired""" - order.expire_date = now() - timedelta(hours=1) - - assert order.expired diff --git a/app/payment/tests/test_payment_task.py b/app/payment/tests/test_payment_task.py new file mode 100644 index 000000000..a62104c8a --- /dev/null +++ b/app/payment/tests/test_payment_task.py @@ -0,0 +1,182 @@ +from django.utils import timezone + +import pytest + +from app.content.factories import EventFactory, RegistrationFactory +from app.content.models import Registration +from app.payment.enums import OrderStatus +from app.payment.factories import OrderFactory +from app.payment.tasks import check_if_has_paid +from app.payment.util.order_utils import check_if_order_is_paid, is_expired + + +@pytest.fixture() +def event(): + return EventFactory() + + +@pytest.fixture() +def registration(event): + return RegistrationFactory(event=event) + + +@pytest.mark.django_db +def test_delete_registration_if_no_orders(event, registration): + """Should delete registration if user has no orders.""" + + check_if_has_paid(event.id, registration.registration_id) + + registration = Registration.objects.filter( + registration_id=registration.registration_id + ).first() + + assert not registration + + +@pytest.mark.django_db +def test_delete_registration_if_no_paid_orders(event, registration): + """Should delete registration if user has no paid orders.""" + + first_order = OrderFactory(event=event, user=registration.user) + second_order = OrderFactory(event=event, user=registration.user) + third_order = OrderFactory(event=event, user=registration.user) + fourth_order = OrderFactory(event=event, user=registration.user) + + first_order.status = OrderStatus.VOID + first_order.save() + second_order.status = OrderStatus.INITIATE + second_order.save() + third_order.status = OrderStatus.CANCEL + third_order.save() + fourth_order.status = OrderStatus.REFUND + fourth_order.save() + + check_if_has_paid(event.id, registration.registration_id) + + registration = Registration.objects.filter( + registration_id=registration.registration_id + ).first() + + assert not registration + + +@pytest.mark.django_db +def test_keep_registration_if_has_paid_order(event, registration): + """Should not delete registration if user has paid order.""" + + first_order = OrderFactory(event=event, user=registration.user) + second_order = OrderFactory(event=event, user=registration.user) + third_order = OrderFactory(event=event, user=registration.user) + + first_order.status = OrderStatus.SALE + first_order.save() + second_order.status = OrderStatus.CANCEL + second_order.save() + third_order.status = OrderStatus.CANCEL + third_order.save() + + check_if_has_paid(event.id, registration.registration_id) + + registration.refresh_from_db() + + assert registration + + +@pytest.mark.django_db +def test_keep_registration_if_has_reserved_order(event, registration): + """Should not delete registration if user has reserved order.""" + + first_order = OrderFactory(event=event, user=registration.user) + second_order = OrderFactory(event=event, user=registration.user) + third_order = OrderFactory(event=event, user=registration.user) + + first_order.status = OrderStatus.RESERVED + first_order.save() + second_order.status = OrderStatus.CANCEL + second_order.save() + third_order.status = OrderStatus.CANCEL + third_order.save() + + check_if_has_paid(event.id, registration.registration_id) + + registration.refresh_from_db() + + assert registration + + +@pytest.mark.django_db +def test_keep_registration_if_has_captured_order(event, registration): + """Should not delete registration if user has captured order.""" + + first_order = OrderFactory(event=event, user=registration.user) + second_order = OrderFactory(event=event, user=registration.user) + third_order = OrderFactory(event=event, user=registration.user) + + first_order.status = OrderStatus.CAPTURE + first_order.save() + second_order.status = OrderStatus.CANCEL + second_order.save() + third_order.status = OrderStatus.CANCEL + third_order.save() + + check_if_has_paid(event.id, registration.registration_id) + + registration.refresh_from_db() + + assert registration + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "order_status", [OrderStatus.CAPTURE, OrderStatus.SALE, OrderStatus.RESERVED] +) +def test_if_order_is_paid(order_status): + """Should return true if order is paid.""" + + order = OrderFactory() + + order.status = order_status + order.save() + + assert check_if_order_is_paid(order) + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "order_status", + [OrderStatus.INITIATE, OrderStatus.VOID, OrderStatus.CANCEL, OrderStatus.REFUND], +) +def test_if_order_is_not_paid(order_status): + """Should return false if order is not paid.""" + + order = OrderFactory() + + order.status = order_status + order.save() + + assert not check_if_order_is_paid(order) + + +@pytest.mark.django_db +def test_if_registration_payment_date_is_expired(registration): + """Should return true if registration payment date is expired.""" + + registration.payment_expiredate = timezone.now() - timezone.timedelta(seconds=1) + registration.save() + + assert is_expired(registration.payment_expiredate) + + registration.payment_expiredate = timezone.now() + registration.save() + + assert is_expired(registration.payment_expiredate) + + +@pytest.mark.django_db +def test_if_registration_payment_date_is_not_expired(registration): + """Should return false if registration payment date is not expired.""" + + registration.payment_expiredate = timezone.now() + timezone.timedelta(seconds=1) + registration.save() + + assert not is_expired(registration.payment_expiredate) diff --git a/app/payment/urls.py b/app/payment/urls.py index c7e2854a6..519b2be49 100644 --- a/app/payment/urls.py +++ b/app/payment/urls.py @@ -1,14 +1,14 @@ -from django.urls import include, path, re_path +from django.urls import include, re_path from rest_framework import routers from app.payment.views.order import OrderViewSet -from app.payment.views.vipps_callback import vipps_callback +from app.payment.views.vipps import VippsViewSet router = routers.DefaultRouter() -router.register("payment", OrderViewSet, basename="payment") +router.register("payments", OrderViewSet, basename="payment") +router.register( + r"v2/payments/(?P[0-9a-f-]+)", VippsViewSet, basename="payment" +) -urlpatterns = [ - re_path(r"", include(router.urls)), - path("v2/payment/", vipps_callback), -] +urlpatterns = [re_path(r"", include(router.urls))] diff --git a/app/payment/util/order_utils.py b/app/payment/util/order_utils.py new file mode 100644 index 000000000..8aa4c4b16 --- /dev/null +++ b/app/payment/util/order_utils.py @@ -0,0 +1,29 @@ +from django.utils import timezone + +from app.payment.enums import OrderStatus + + +def has_paid_order(orders): + if not orders: + return False + + for order in orders: + if check_if_order_is_paid(order): + return True + + return False + + +def check_if_order_is_paid(order): + if order and ( + order.status == OrderStatus.CAPTURE + or order.status == OrderStatus.RESERVED + or order.status == OrderStatus.SALE + ): + return True + + return False + + +def is_expired(expire_date): + return expire_date <= timezone.now() diff --git a/app/payment/util/payment_utils.py b/app/payment/util/payment_utils.py index 96bd5ce1f..e7e2f6118 100644 --- a/app/payment/util/payment_utils.py +++ b/app/payment/util/payment_utils.py @@ -1,4 +1,6 @@ import json +import os +from datetime import datetime from django.conf import settings @@ -27,7 +29,45 @@ def get_new_access_token(): return (response["expires_on"], response["access_token"]) -def initiate_payment(amount, order_id, event_name, access_token): +def check_access_token(): + """ + Checks for access token. + Updates acces token if expired. + Returns new access token. + """ + access_token = os.environ.get("PAYMENT_ACCESS_TOKEN") + expires_at = os.environ.get("PAYMENT_ACCESS_TOKEN_EXPIRES_AT") + + if not access_token or datetime.now() >= datetime.fromtimestamp(int(expires_at)): + (expires_at, access_token) = get_new_access_token() + os.environ.update({"PAYMENT_ACCESS_TOKEN": access_token}) + os.environ.update({"PAYMENT_ACCESS_TOKEN_EXPIRES_AT": str(expires_at)}) + + return access_token + + +def get_payment_order_status(order_id): + """ + Returns status of payment order. + """ + + access_token = check_access_token() + + url = f"{settings.VIPPS_ORDER_URL}{order_id}/details" + headers = { + "Content-Type": "application/json", + "Ocp-Apim-Subscription-Key": settings.VIPPS_SUBSCRIPTION_KEY, + "Authorization": "Bearer " + access_token, + "Merchant-Serial-Number": settings.VIPPS_MERCHANT_SERIAL_NUMBER, + } + + res = requests.get(url, headers=headers) + json = res.json() + + return json["transactionLogHistory"][0]["operation"] + + +def initiate_payment(amount, order_id, access_token, transaction_text, fallback): """ Initiate a payment with Vipps amount: Amount to pay in Øre (100 NOK = 10000) @@ -37,14 +77,15 @@ def initiate_payment(amount, order_id, event_name, access_token): { "merchantInfo": { "callbackPrefix": settings.VIPPS_CALLBACK_PREFIX, - "fallBack": settings.VIPPS_FALLBACK, + "fallBack": f"{settings.VIPPS_FALLBACK}{fallback}", "merchantSerialNumber": settings.VIPPS_MERCHANT_SERIAL_NUMBER, }, "transaction": { "amount": amount, - "transactionText": "This payment is for the event:" + event_name, + "transactionText": transaction_text, "orderId": order_id, "skipLandingPage": False, + "scope": "name phoneNumber", }, } ) @@ -61,3 +102,37 @@ def initiate_payment(amount, order_id, event_name, access_token): raise Exception("Could not initiate payment") return response.json() + + +def refund_payment(amount, order_id, access_token, transaction_text): + """ + Refund a payment from Vipps + amount: Amount to pay in Øre (100 NOK = 10000) + """ + + url = f"{settings.VIPPS_ORDER_URL}/{order_id}/refund/" + + payload = json.dumps( + { + "merchantInfo": { + "merchantSerialNumber": settings.VIPPS_MERCHANT_SERIAL_NUMBER, + }, + "transaction": { + "amount": amount, + "transactionText": transaction_text, + }, + } + ) + + headers = { + "Content-Type": "application/json", + "Ocp-Apim-Subscription-Key": settings.VIPPS_SUBSCRIPTION_KEY, + "Authorization": "Bearer " + access_token, + "Merchant-Serial-Number": settings.VIPPS_MERCHANT_SERIAL_NUMBER, + "X-Request-Id": order_id, + } + + response = requests.post(url, headers=headers, data=payload) + + if response.status_code != 200: + raise Exception("Could not refund payment") diff --git a/app/payment/views/order.py b/app/payment/views/order.py index 49ddb2859..822a4c978 100644 --- a/app/payment/views/order.py +++ b/app/payment/views/order.py @@ -4,12 +4,16 @@ from sentry_sdk import capture_exception from app.common.mixins import ActionMixin +from app.common.permissions import BasicViewPermission from app.common.viewsets import BaseViewSet +from app.content.models import Registration, User from app.payment.models import Order -from app.payment.serializers import OrderSerializer +from app.payment.serializers import OrderCreateSerializer, OrderSerializer +from app.payment.util.order_utils import is_expired class OrderViewSet(BaseViewSet, ActionMixin): + permission_classes = [BasicViewPermission] serializer_class = OrderSerializer queryset = Order.objects.all() @@ -17,9 +21,9 @@ def retrieve(self, request, pk): try: user = request.query_params.get("user_id") event = request.query_params.get("event") - order = Order.objects.filter(user=user, event=event)[0] + orders = Order.objects.filter(user=user, event=event) serializer = OrderSerializer( - order, context={"request": request}, many=False + orders, context={"request": request}, many=True ) return Response(serializer.data, status.HTTP_200_OK) except Order.DoesNotExist as order_not_exist: @@ -28,3 +32,37 @@ def retrieve(self, request, pk): {"detail": "Fant ikke beatlingsordre."}, status=status.HTTP_404_NOT_FOUND, ) + + def create(self, request, *args, **kwargs): + try: + user = request.user + event = request.data.get("event") + registration = Registration.objects.get(user=user, event=event) + + if is_expired(registration.payment_expiredate): + return Response( + {"detail": "Din betalingstid er utgått"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = OrderCreateSerializer( + data=request.data, + context={"request": request}, + ) + + if serializer.is_valid(): + order = super().perform_create(serializer, user=user) + serializer = OrderSerializer( + order, context={"request": request}, many=False + ) + + return Response(serializer.data, status.HTTP_201_CREATED) + + return Response(serializer.errors, status.HTTP_400_BAD_REQUEST) + + except User.DoesNotExist as user_not_exist: + capture_exception(user_not_exist) + return Response( + {"detail": "Fant ikke bruker."}, + status=status.HTTP_404_NOT_FOUND, + ) diff --git a/app/payment/views/vipps.py b/app/payment/views/vipps.py new file mode 100644 index 000000000..0cac12686 --- /dev/null +++ b/app/payment/views/vipps.py @@ -0,0 +1,42 @@ +from django.conf import settings +from rest_framework import status +from rest_framework.response import Response + +from sentry_sdk import capture_exception + +from app.common.permissions import BasicViewPermission +from app.common.viewsets import BaseViewSet +from app.payment.models import Order +from app.payment.serializers import VippsOrderSerialzer + + +class VippsViewSet(BaseViewSet): + permission_classes = [BasicViewPermission] + serializer_class = VippsOrderSerialzer + queryset = Order.objects.all() + + def create(self, request, order_id): + try: + order = Order.objects.get(order_id=order_id) + data = request.data + + MSN = data.get("merchantSerialNumber") + if int(MSN) != int(settings.VIPPS_MERCHANT_SERIAL_NUMBER): + return Response( + {"detail": "Merchant serial number matcher ikke"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + transaction_info = data.get("transactionInfo") + if transaction_info: + new_status = transaction_info["status"] + order.status = new_status + order.save() + + return Response(status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response( + {"detail": "Kunne ikke oppdatere ordre"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/app/payment/views/vipps_callback.py b/app/payment/views/vipps_callback.py index 63b95c607..754564d79 100644 --- a/app/payment/views/vipps_callback.py +++ b/app/payment/views/vipps_callback.py @@ -2,29 +2,11 @@ import requests -from app.payment.models.order import Order from app.payment.util.payment_utils import get_new_access_token -def vipps_callback(_request, order_id): - access_token = get_new_access_token()[1] - url = f"{settings.VIPPS_ORDER_URL}{order_id}/details" - headers = { - "Content-Type": "application/json", - "Ocp-Apim-Subscription-Key": settings.VIPPS_SUBSCRIPTION_KEY, - "Authorization": "Bearer " + access_token, - "Merchant-Serial-Number": settings.VIPPS_MERCHANT_SERIAL_NUMBER, - } - res = requests.get(url, headers=headers) - json = res.json() - status = json["transactionLogHistory"][0]["operation"] - order = Order.objects.get(order_id=order_id) - order.status = status - order.save() - return status - - def force_payment(order_id): + """Force payment for an order.""" access_token = get_new_access_token()[1] url = f"{settings.VIPPS_FORCE_PAYMENT_URL}{order_id}/approve" headers = { diff --git a/app/tests/conftest.py b/app/tests/conftest.py index 29e6d7110..38c7ee57e 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -32,6 +32,7 @@ from app.group.factories import GroupFactory, MembershipFactory from app.group.factories.fine_factory import FineFactory from app.group.factories.membership_factory import MembershipHistoryFactory +from app.payment.factories.order_factory import OrderFactory from app.payment.factories.paid_event_factory import PaidEventFactory from app.util.test_utils import add_user_to_group_with_name, get_api_client @@ -142,6 +143,11 @@ def group(): return GroupFactory() +@pytest.fixture() +def order(): + return OrderFactory() + + @pytest.fixture() def membership(): return MembershipFactory(membership_type=MembershipType.MEMBER) diff --git a/app/tests/content/test_event_integration.py b/app/tests/content/test_event_integration.py index 89eae6157..bf04f50ef 100644 --- a/app/tests/content/test_event_integration.py +++ b/app/tests/content/test_event_integration.py @@ -50,6 +50,33 @@ def get_event_data( return data +def get_paid_event_data( + price, + paytime, + title="New Title", + location="New Location", + organizer=None, + contact_person=None, + limit=0, +): + start_date = timezone.now() + timedelta(days=10) + end_date = timezone.now() + timedelta(days=11) + data = { + "title": title, + "location": location, + "start_date": start_date, + "end_date": end_date, + "is_paid_event": True, + "paid_information": {"price": price, "paytime": paytime}, + "limit": limit, + } + if organizer: + data["organizer"] = organizer + if contact_person: + data["contact_person"] = contact_person + return data + + # "event_current_organizer"/"event_new_organizer" should have one of 3 different values: # - None -> The event has no connected organizer/should remove connection to organizer # - "same" -> The event is connected to/should be connected to same organizer as user is member of @@ -841,3 +868,103 @@ def test_jubkom_has_create_permission(api_client, jubkom_member): response = client.post(API_EVENTS_BASE_URL, data) assert response.status_code == status.HTTP_201_CREATED + + +@pytest.mark.django_db +def test_update_from_free_event_with_participants_to_paid_event( + api_client, admin_user, event, registration +): + """ + An admin should not be able to update a free event with participants to a paid event. + """ + + registration.event = event + registration.is_on_wait = False + registration.save() + + url = f"{API_EVENTS_BASE_URL}{event.id}/" + client = api_client(user=admin_user) + data = get_paid_event_data(price=200, paytime="01:00", limit=1) + + response = client.put(url, data) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_update_from_paid_event_with_participants_to_free_event( + api_client, admin_user, event, paid_event, registration +): + """ + An admin should not be able to update a paid event with participants to a free event. + """ + paid_event.event = event + paid_event.save() + + registration.event = event + registration.is_on_wait = False + registration.save() + + url = f"{API_EVENTS_BASE_URL}{event.id}/" + client = api_client(user=admin_user) + data = get_event_data(limit=1) + + response = client.put(url, data) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_update_from_paid_event_to_free_event( + api_client, admin_user, event, paid_event +): + """ + An admin should be able to update a paid event with no participants to a free event. + """ + paid_event.event = event + paid_event.save() + + url = f"{API_EVENTS_BASE_URL}{event.id}/" + client = api_client(user=admin_user) + data = get_event_data(limit=0) + + response = client.put(url, data) + + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_update_from_free_event_to_paid_event(api_client, admin_user, event): + """ + An admin should be able to update a free event with no participants to a paid event. + """ + url = f"{API_EVENTS_BASE_URL}{event.id}/" + client = api_client(user=admin_user) + data = get_paid_event_data(price=200, paytime="01:00", limit=1) + + response = client.put(url, data) + + data = response.json() + + assert response.status_code == status.HTTP_200_OK + assert data["is_paid_event"] + assert data["paid_information"]["price"] == "200.00" + assert data["paid_information"]["paytime"] == "01:00:00" + + +@pytest.mark.django_db +def test_create_paid_event(api_client, admin_user): + """ + An admin should be able to create a paid event. + """ + client = api_client(user=admin_user) + data = get_paid_event_data(price=200, paytime="01:00", limit=1) + + response = client.post(API_EVENTS_BASE_URL, data) + + data = response.json() + + assert response.status_code == status.HTTP_201_CREATED + assert data["is_paid_event"] + assert data["paid_information"]["price"] == "200.00" + assert data["paid_information"]["paytime"] == "01:00:00" diff --git a/app/tests/content/test_registration_integration.py b/app/tests/content/test_registration_integration.py index 2fb5e89ce..772003d0d 100644 --- a/app/tests/content/test_registration_integration.py +++ b/app/tests/content/test_registration_integration.py @@ -10,8 +10,6 @@ from app.forms.enums import EventFormType from app.forms.tests.form_factories import EventFormFactory, SubmissionFactory from app.group.factories import GroupFactory -from app.payment.enums import OrderStatus -from app.payment.models import Order from app.util.test_utils import add_user_to_group_with_name, get_api_client from app.util.utils import now @@ -1003,41 +1001,6 @@ def test_add_registration_to_event_as_admin_group_member_after_registration_clos assert response.status_code == status.HTTP_201_CREATED -@pytest.mark.django_db -@pytest.mark.parametrize( - "organizer_name", - [ - AdminGroup.PROMO, - AdminGroup.NOK, - AdminGroup.KOK, - AdminGroup.SOSIALEN, - AdminGroup.INDEX, - ], -) -def test_add_registration_to_paid_event_as_admin_group_member( - paid_event, member, organizer_name -): - """ - A member of NOK, Promo, Sosialen or KOK should be able to add a - registration to a paid event manually. A order with status "SALE" should be created. - """ - - data = {"user": member.user_id, "event": paid_event.event.id} - url = f"{_get_registration_url(event=paid_event.event)}add/" - - client = get_api_client(user=member, group_name=organizer_name) - - response = client.post(url, data) - - assert response.status_code == status.HTTP_201_CREATED - - order = Order.objects.filter( - user=member, event=paid_event.event, status=OrderStatus.SALE - ) - - assert order - - @pytest.mark.django_db def test_add_registration_to_event_as_anonymous_user(default_client, event, member): """ diff --git a/app/tests/payment/test_order_integration.py b/app/tests/payment/test_order_integration.py deleted file mode 100644 index 55d2413fc..000000000 --- a/app/tests/payment/test_order_integration.py +++ /dev/null @@ -1,21 +0,0 @@ -API_EVENT_BASE_URL = "/events/" -API_PAYMENT_BASE_URL = "/payment/" - - -def _get_registration_url(event): - return f"{API_EVENT_BASE_URL}{event.pk}/registrations/" - - -def _get_order_url(): - return f"{API_PAYMENT_BASE_URL}order/" - - -def _get_registration_post_data(user, event): - return { - "user_id": user.user_id, - "event": event.pk, - } - - -def _get_order_data(user, event): - return {"user_id": user.user_id, "event": event.pk} diff --git a/app/tests/payment/test_paid_event_integration.py b/app/tests/payment/test_paid_event_integration.py index a759cdfa5..b6f629688 100644 --- a/app/tests/payment/test_paid_event_integration.py +++ b/app/tests/payment/test_paid_event_integration.py @@ -136,9 +136,11 @@ def test_update_paid_event_as_admin(admin_user): @pytest.mark.django_db -def test_update_paid_event_to_free_event_as_admin(admin_user): +def test_update_paid_event_to_free_event_with_registrations_as_admin( + admin_user, registration +): """ - HS and Index members should not be able to update a paid event to a free event. + HS and Index members should not be able to update a paid event with registrations to a free event. Other subgroup members can update paid events where event.organizer is their group or None. Leaders of committees and interest groups should be able to update events where event.organizer is their group or None. @@ -146,6 +148,10 @@ def test_update_paid_event_to_free_event_as_admin(admin_user): paid_event = PaidEventFactory(price=100.00) event = paid_event.event + + registration.event = event + registration.save() + organizer = Group.objects.get_or_create(name="HS", type=GroupType.BOARD)[0] client = get_api_client(user=admin_user) url = get_events_url_detail(event) @@ -156,3 +162,27 @@ def test_update_paid_event_to_free_event_as_admin(admin_user): assert response.status_code == 400 assert event.is_paid_event + + +@pytest.mark.django_db +def test_update_paid_event_to_free_event_without_registrations_as_admin(admin_user): + """ + HS and Index members should be able to update a paid event without registrations to a free event. + Other subgroup members can update paid events where event.organizer is their group or None. + Leaders of committees and interest groups should be able to + update events where event.organizer is their group or None. + """ + + paid_event = PaidEventFactory(price=100.00) + event = paid_event.event + + organizer = Group.objects.get_or_create(name="HS", type=GroupType.BOARD)[0] + client = get_api_client(user=admin_user) + url = get_events_url_detail(event) + data = get_paid_event_data(organizer=organizer.slug, is_paid_event=False) + + response = client.put(url, data) + event.refresh_from_db() + + assert response.status_code == 200 + assert not event.is_paid_event diff --git a/app/tests/payment/test_vipps_callback.py b/app/tests/payment/test_vipps_callback.py index 5b9f4e544..8a4201fa9 100644 --- a/app/tests/payment/test_vipps_callback.py +++ b/app/tests/payment/test_vipps_callback.py @@ -1,49 +1,45 @@ -API_EVENT_BASE_URL = "/events/" +from django.conf import settings +import pytest -def _get_registration_url(event): - return f"{API_EVENT_BASE_URL}{event.pk}/registrations/" +from app.payment.enums import OrderStatus +from app.payment.factories import OrderFactory -def _get_registration_post_data(user, event): +def get_callback_data(order_id, status): return { - "user_id": user.user_id, - "event": event.pk, + "merchantSerialNumber": settings.VIPPS_MERCHANT_SERIAL_NUMBER, + "orderId": order_id, + "transactionInfo": { + "amount": 20000, + "status": status, + "timeStamp": "2018-12-12T11:18:38.246Z", + "transactionId": "5001420062", + }, } -# these tests can not be tested because of celery - -# @pytest.mark.django_db -# def test_if_order_gets_updated_by_vipps_callback(member, paid_event): -# """A member should be able to create a registration for themselves.""" -# data = _get_registration_post_data(member, paid_event) -# client = get_api_client(user=member) - -# url = _get_registration_url(event=paid_event.event) -# response = client.post(url, data=data) - -# assert response.status_code == status.HTTP_201_CREATED - -# order = Order.objects.all()[0] -# order_id = order.order_id -# order_status = order.status -# new_status = vipps_callback({"orderId": order_id}) - -# assert order_status == new_status - -# @pytest.mark.django_db -# def test_force_vipps_payment(member, paid_event): -# """A member should be able to create a registration for themselves.""" -# data = _get_registration_post_data(member, paid_event) -# client = get_api_client(user=member) - -# url = _get_registration_url(event=paid_event.event) -# response = client.post(url, data=data) - -# assert response.status_code == status.HTTP_201_CREATED - -# order = Order.objects.all()[0] -# json, status_code = force_payment(order.order_id) -# print(status_code) -# print(json) +@pytest.mark.django_db +@pytest.mark.parametrize( + "status", + [ + OrderStatus.RESERVED, + OrderStatus.CAPTURE, + OrderStatus.REFUND, + OrderStatus.CANCEL, + OrderStatus.SALE, + OrderStatus.VOID, + ], +) +def test_update_order_status_by_vipps_callback(default_client, status): + """Should update order status.""" + + order = OrderFactory(status=OrderStatus.INITIATE) + order_id = order.order_id + + data = get_callback_data(order_id, status) + response = default_client.post(f"/v2/payments/{order_id}/", data=data) + order.refresh_from_db() + + assert response.status_code == 200 + assert order.status == status