diff --git a/backend/clubs/migrations/0121_tickettransactionrecord_created_at_and_more.py b/backend/clubs/migrations/0121_tickettransactionrecord_created_at_and_more.py new file mode 100644 index 000000000..918ab84e8 --- /dev/null +++ b/backend/clubs/migrations/0121_tickettransactionrecord_created_at_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 5.0.4 on 2025-01-18 16:35 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0120_ticketsettings_active"), + ] + + operations = [ + migrations.AddField( + model_name="tickettransactionrecord", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, default=django.utils.timezone.now + ), + preserve_default=False, + ), + migrations.AddField( + model_name="tickettransactionrecord", + name="refunded", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="tickettransactionrecord", + name="updated_at", + field=models.DateTimeField(auto_now=True), + ), + migrations.AlterField( + model_name="tickettransactionrecord", + name="buyer_email", + field=models.EmailField(default="contact@pennclubs.com", max_length=254), + preserve_default=False, + ), + ] diff --git a/backend/clubs/models.py b/backend/clubs/models.py index 3c21cd8e8..3d3c33e87 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -1869,10 +1869,49 @@ class TicketTransactionRecord(models.Model): reconciliation_id = models.CharField(max_length=100, null=True, blank=True) total_amount = models.DecimalField(max_digits=5, decimal_places=2) - buyer_phone = PhoneNumberField(null=True, blank=True) buyer_first_name = models.CharField(max_length=100) buyer_last_name = models.CharField(max_length=100) - buyer_email = models.EmailField(blank=True, null=True) + buyer_email = models.EmailField() + buyer_phone = PhoneNumberField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + refunded = models.BooleanField(default=False) + + def send_confirmation_email(self): + """ + Send confirmation emails to both the buyer and event organizers after a refund + """ + if not self.refunded: + raise ValueError( + "Cannot send refund confirmation for non-refunded transaction." + ) + + tickets = self.tickets.all() + events = set(ticket.event for ticket in tickets) + + club_emails = set() + for event in events: + if event.club.email: + club_emails.add(event.club.email) + + officer_emails = Membership.objects.filter( + club=event.club, role__lte=Membership.ROLE_OFFICER + ).values_list("person__email", flat=True) + club_emails.update(officer_emails) + + context = { + "first_name": self.buyer_first_name, + "event_names": ", ".join(event.name for event in events), + "amount": "{:.2f}".format(self.total_amount), + } + + send_mail_helper( + name="ticket_refund", + subject="Your ticket purchase has been refunded", + emails=[self.buyer_email], + bcc=list(club_emails), + context=context, + ) class Ticket(models.Model): diff --git a/backend/clubs/permissions.py b/backend/clubs/permissions.py index 3511ecd24..9640d8b4f 100644 --- a/backend/clubs/permissions.py +++ b/backend/clubs/permissions.py @@ -1,7 +1,7 @@ from django.contrib.auth import get_user_model from rest_framework import permissions -from clubs.models import Club, Membership +from clubs.models import Club, Event, Membership def codes_extract_helper(obj, key): @@ -562,3 +562,43 @@ def has_permission(self, request, view): return True else: return request.user.is_authenticated + + +class TicketTransactionPermission(permissions.BasePermission): + """ + Superusers can see and refund all transactions. Else, event must be specified. + Officers and above can see and refund transactions for specified events. + """ + + def has_permission(self, request, view): + if not request.user.is_authenticated: + return False + + if request.user.is_superuser: + return True + + if request.user.has_perm("clubs.manage_club"): + return True + + event_id = request.query_params.get("event_id", None) + if not event_id: + return False + + try: + event = Event.objects.get(id=event_id) + membership = find_membership_helper(request.user, event.club) + return membership is not None and membership.role <= Membership.ROLE_OFFICER + except Event.DoesNotExist: + return False + + def has_object_permission(self, request, view, obj): + if request.user.is_superuser: + return True + + if not request.user.is_authenticated: + return False + + membership = find_membership_helper( + request.user, obj.tickets.first().event.club + ) + return membership is not None and membership.role <= Membership.ROLE_OFFICER diff --git a/backend/clubs/serializers.py b/backend/clubs/serializers.py index fc9f1a095..16790b622 100644 --- a/backend/clubs/serializers.py +++ b/backend/clubs/serializers.py @@ -57,6 +57,7 @@ TargetYear, Testimonial, Ticket, + TicketTransactionRecord, Year, ) from clubs.utils import clean @@ -1797,6 +1798,32 @@ class Meta: fields = ("id", "event", "type", "owner", "attended", "price") +class TicketTransactionRecordSerializer(serializers.ModelSerializer): + """ + Serializer for ticket transaction records, including related ticket information + and buyer details. + """ + + tickets = TicketSerializer(many=True, read_only=True) + + class Meta: + model = TicketTransactionRecord + fields = [ + "id", + "reconciliation_id", + "total_amount", + "buyer_first_name", + "buyer_last_name", + "buyer_email", + "buyer_phone", + "tickets", + "refunded", + "created_at", + "updated_at", + ] + read_only_fields = fields + + class UserUUIDSerializer(serializers.ModelSerializer): """ Used to get the uuid of a user (for ICS Calendar export) diff --git a/backend/clubs/urls.py b/backend/clubs/urls.py index 48f2f0572..dc275c9c8 100644 --- a/backend/clubs/urls.py +++ b/backend/clubs/urls.py @@ -45,6 +45,7 @@ SubscribeViewSet, TagViewSet, TestimonialViewSet, + TicketTransactionRecordViewSet, TicketViewSet, UserGroupAPIView, UserPermissionAPIView, @@ -73,6 +74,9 @@ router.register(r"memberships", MembershipViewSet, basename="members") router.register(r"requests", MembershipRequestViewSet, basename="requests") router.register(r"tickets", TicketViewSet, basename="tickets") +router.register( + r"transactions", TicketTransactionRecordViewSet, basename="transactions" +) router.register(r"schools", SchoolViewSet, basename="schools") router.register(r"majors", MajorViewSet, basename="majors") diff --git a/backend/clubs/views.py b/backend/clubs/views.py index 6b2d89843..3feab2328 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -142,6 +142,7 @@ ProfilePermission, QuestionAnswerPermission, ReadOnly, + TicketTransactionPermission, WhartonApplicationPermission, find_membership_helper, ) @@ -193,6 +194,7 @@ TagSerializer, TestimonialSerializer, TicketSerializer, + TicketTransactionRecordSerializer, UserClubVisitSerializer, UserClubVisitWriteSerializer, UserMembershipInviteSerializer, @@ -5911,9 +5913,8 @@ def _give_tickets(user, order_info, cart, reconciliation_id): total_amount=float(order_info["amountDetails"]["totalAmount"]), buyer_first_name=order_info["billTo"]["firstName"], buyer_last_name=order_info["billTo"]["lastName"], - # TODO: investigate why phone numbers don't show in test API - buyer_phone=order_info["billTo"].get("phoneNumber", None), buyer_email=order_info["billTo"]["email"], + buyer_phone=order_info["billTo"]["phoneNumber"], ) tickets.update(owner=user, holder=None, transaction_record=transaction_record) @@ -5937,6 +5938,106 @@ def _place_hold_on_tickets(user, tickets): tickets.update(holder=user, holding_expiration=holding_expiration) +class TicketTransactionRecordViewSet(viewsets.ModelViewSet): + """ + ViewSet for managing ticket transaction records. + + list: Get transaction records for specified event. Superusers can see all records. + retrieve: Get details of a specific transaction record. + refund: Process refund of a transaction. Frees up tickets and sends emails. + """ + + queryset = TicketTransactionRecord.objects.all() + permission_classes = [TicketTransactionPermission | IsSuperuser] + serializer_class = TicketTransactionRecordSerializer + http_method_names = ["get"] + + def get_queryset(self): + queryset = super().get_queryset() + + if ( + self.request.user.has_perm("clubs.generate_reports") + or self.request.user.is_superuser + ): + return queryset + + event_id = self.request.query_params.get("event_id", None) + if not event_id: + return queryset.none() + + return queryset.filter(tickets__event_id=event_id) + + @action(detail=True, methods=["post"]) + def refund(self, request, *args, **kwargs): + """ + Process a refund for a transaction record. + + Marks the transaction as refunded, makes tickets available again, + and sends confirmation emails to the buyer and event organizer. + --- + requestBody: + content: + application/json: + schema: + type: object + properties: {} + required: [] + responses: + "200": + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + detail: + type: string + "400": + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + detail: + type: string + --- + """ + transaction = self.get_object() + + # Check if already refunded + if transaction.refunded: + return Response( + { + "success": False, + "detail": "This transaction has already been refunded.", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # TODO: refund tickets through CyberSource API + + with transaction.atomic(): + transaction.refunded = True + transaction.save(update_fields=["refunded"]) + + # Make tickets available again + tickets = transaction.tickets.all() + tickets.update(owner=None, holder=None, transaction_record=None) + + # Send confirmation emails + transaction.send_refund_confirmation_emails() + + return Response( + { + "success": True, + "detail": "Successfully processed refund and notified all parties.", + } + ) + + class MemberInviteViewSet(viewsets.ModelViewSet): """ update: diff --git a/backend/templates/emails/ticket_refund.html b/backend/templates/emails/ticket_refund.html new file mode 100644 index 000000000..09ebe43c9 --- /dev/null +++ b/backend/templates/emails/ticket_refund.html @@ -0,0 +1,26 @@ + + +{% extends 'emails/base.html' %} + +{% block content %} +
+ {{ first_name }}, this email confirms that your tickets for {{ event_names }} have been refunded. The refund amount of ${{ amount }} will be processed back to your original payment method. +
+ ++ The refunded tickets are no longer valid for entry to the event(s). If you would like to attend in the future, you will need to purchase new tickets if they are still available. +
+ ++ If you have any questions about this refund, feel free to respond to this email. +
+{% endblock %}