From bc6e113fc4d310d3ed98d7e6325bfe77646e716a Mon Sep 17 00:00:00 2001 From: James Kachel Date: Fri, 31 Jan 2025 14:57:57 -0600 Subject: [PATCH] Redid refunds models to add link to transaction table, added serializers, added view folders, other small updates --- payments/models.py | 6 + refunds/__init__.py | 0 refunds/admin.py | 33 +++ refunds/api.py | 1 + refunds/apps.py | 8 + .../0001_add_initial_refunds_models.py | 256 ++++++++++++++++++ refunds/migrations/__init__.py | 0 refunds/models.py | 171 ++++++++++++ refunds/serializers/__init__.py | 0 refunds/serializers/v0/__init__.py | 41 +++ refunds/views/__init__.py | 0 refunds/views/v0/__init__.py | 1 + unified_ecommerce/constants.py | 8 + unified_ecommerce/settings.py | 1 + 14 files changed, 526 insertions(+) create mode 100644 refunds/__init__.py create mode 100644 refunds/admin.py create mode 100644 refunds/api.py create mode 100644 refunds/apps.py create mode 100644 refunds/migrations/0001_add_initial_refunds_models.py create mode 100644 refunds/migrations/__init__.py create mode 100644 refunds/models.py create mode 100644 refunds/serializers/__init__.py create mode 100644 refunds/serializers/v0/__init__.py create mode 100644 refunds/views/__init__.py create mode 100644 refunds/views/v0/__init__.py diff --git a/payments/models.py b/payments/models.py index 0263fd39..3b88e299 100644 --- a/payments/models.py +++ b/payments/models.py @@ -719,6 +719,12 @@ def save(self, *args, **kwargs): self.reference_number = self._generate_reference_number() super().save(*args, **kwargs) + @cached_property + def system(self): + """Return the system associated with the order.""" + + return self.lines.first().product_version.system + # Flag to determine if the order is in review status - if it is, then # we need to not step on the basket that may or may not exist when it is # accepted diff --git a/refunds/__init__.py b/refunds/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/refunds/admin.py b/refunds/admin.py new file mode 100644 index 00000000..dd21bb8d --- /dev/null +++ b/refunds/admin.py @@ -0,0 +1,33 @@ +"""Admin for the refunds app.""" + +from django.contrib import admin + +from refunds.models import Request, RequestRecipient + + +class RequestRecipientAdmin(admin.ModelAdmin): + """Admin for RequestRecipient.""" + + list_display = ("email", "system") + search_fields = ("email", "system") + list_filter = ("system",) + + +class RequestAdmin(admin.ModelAdmin): + """Admin for Request.""" + + list_display = ("requester_email", "order", "processed_date", "processed_by_email") + + @admin.display(description="Requester") + def requester_email(self, obj): + """Return the requester's email.""" + return obj.requester.email + + @admin.display(description="Processed by") + def processed_by_email(self, obj): + """Return the processed by user's email.""" + return obj.processed_by.email + + +admin.site.register(RequestRecipient, RequestRecipientAdmin) +admin.site.register(Request, RequestAdmin) diff --git a/refunds/api.py b/refunds/api.py new file mode 100644 index 00000000..b48159b9 --- /dev/null +++ b/refunds/api.py @@ -0,0 +1 @@ +"""API functions for refunds.""" diff --git a/refunds/apps.py b/refunds/apps.py new file mode 100644 index 00000000..4f682aa8 --- /dev/null +++ b/refunds/apps.py @@ -0,0 +1,8 @@ +"""App config for refunds.""" + +from django.apps import AppConfig + + +class RefundsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "refunds" diff --git a/refunds/migrations/0001_add_initial_refunds_models.py b/refunds/migrations/0001_add_initial_refunds_models.py new file mode 100644 index 00000000..30a62b8a --- /dev/null +++ b/refunds/migrations/0001_add_initial_refunds_models.py @@ -0,0 +1,256 @@ +# Generated by Django 4.2.17 on 2025-01-31 20:14 + +import uuid +from decimal import Decimal + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("system_meta", "0009_product_details_url"), + ("payments", "0014_alter_discount_payment_type"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Request", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("updated_on", models.DateTimeField(auto_now=True)), + ("processed_date", models.DateTimeField(blank=True, null=True)), + ( + "total_refunded", + models.DecimalField( + blank=True, + decimal_places=5, + default=Decimal("0"), + max_digits=20, + null=True, + ), + ), + ( + "status", + models.CharField( + blank=True, + choices=[ + ("pending", "pending"), + ("created", "created"), + ("denied", "denied"), + ("approved", "approved"), + ("failed", "failed"), + ], + default="created", + max_length=20, + ), + ), + ( + "zendesk_ticket", + models.CharField(blank=True, default="", max_length=255), + ), + ( + "order", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="refund_requests", + to="payments.order", + ), + ), + ( + "processed_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="processed_refund_requests", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "requester", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="refund_requests", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="RequestRecipient", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("updated_on", models.DateTimeField(auto_now=True)), + ( + "email", + models.EmailField( + help_text="The email address to send refund requests to.", + max_length=254, + unique=True, + ), + ), + ( + "system", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="system_meta.integratedsystem", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="RequestProcessingCode", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("updated_on", models.DateTimeField(auto_now=True)), + ( + "email", + models.EmailField( + help_text="The email address the code was sent to.", + max_length=254, + ), + ), + ( + "code_batch", + models.UUIDField( + blank=True, + help_text="Batch ID, generated when the codes are generated.", + null=True, + ), + ), + ("approve_code", models.UUIDField(default=uuid.uuid4, editable=False)), + ("deny_code", models.UUIDField(default=uuid.uuid4, editable=False)), + ("code_active", models.BooleanField(default=True)), + ( + "code_used", + models.CharField( + blank=True, + choices=[("approve", "approve"), ("deny", "deny")], + default="", + max_length=20, + ), + ), + ("code_used_on", models.DateTimeField(blank=True, null=True)), + ( + "refund_request", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="process_code", + to="refunds.request", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="RequestLine", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("updated_on", models.DateTimeField(auto_now=True)), + ( + "status", + models.CharField( + blank=True, + choices=[ + ("pending", "pending"), + ("created", "created"), + ("denied", "denied"), + ("approved", "approved"), + ("failed", "failed"), + ], + default="created", + help_text="The status of this line item.", + max_length=20, + ), + ), + ( + "refunded_amount", + models.DecimalField( + blank=True, + decimal_places=5, + default=Decimal("0"), + help_text="The amount refunded for this line item (may not be the full amount charged).", + max_digits=20, + null=True, + ), + ), + ( + "line", + models.ForeignKey( + help_text="The individual line item to refund.", + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="payments.line", + ), + ), + ( + "refund_request", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="lines", + to="refunds.request", + ), + ), + ( + "transactions", + models.ManyToManyField( + blank=True, + related_name="refund_request_lines", + to="payments.transaction", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/refunds/migrations/__init__.py b/refunds/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/refunds/models.py b/refunds/models.py new file mode 100644 index 00000000..6b06c979 --- /dev/null +++ b/refunds/models.py @@ -0,0 +1,171 @@ +"""Models for refund processing.""" +# ruff: noqa: TD002,TD003,FIX002 + +import logging +import uuid +from decimal import Decimal + +from django.conf import settings +from django.db import models +from mitol.common.models import TimestampedModel + +from payments.models import Line, Order, Transaction +from system_meta.models import IntegratedSystem +from unified_ecommerce.constants import ( + REFUND_CODE_TYPE_CHOICES, + REFUND_STATUS_CHOICES, + REFUND_STATUS_CREATED, +) +from unified_ecommerce.plugin_manager import get_plugin_manager + +log = logging.getLogger(__name__) +pm = get_plugin_manager() + + +class RequestRecipient(TimestampedModel): + """ + Stores recipients for refund request emails. + + Refund requests may be sent to an email address for processing. This may not + be someone who has an account in the system - it could be a mailing list or + generic support address (or helpdesk system). + + No direct FKs to this - the processing codes capture the email address so + this list can change at will. + """ + + email = models.EmailField( + unique=True, help_text="The email address to send refund requests to." + ) + system = models.ForeignKey( + IntegratedSystem, + on_delete=models.CASCADE, + ) + + def __str__(self): + """Return a reasonable string representation of the recipient.""" + + return f"{self.email} ({self.system.name})" + + +class Request(TimestampedModel): + """Contains requests for refunds""" + + requester = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="refund_requests", + ) + # Allow for multiple requests for an order - there's potential for the request + # to be declined, or for more than one request to be made for different lines. + order = models.ForeignKey( + Order, on_delete=models.PROTECT, related_name="refund_requests" + ) + + processed_date = models.DateTimeField(null=True, blank=True) + processed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="processed_refund_requests", + null=True, + blank=True, + ) + + total_refunded = models.DecimalField( + decimal_places=5, + max_digits=20, + default=Decimal(0), + blank=True, + null=True, + ) + status = models.CharField( + max_length=20, + choices=REFUND_STATUS_CHOICES, + default=REFUND_STATUS_CREATED, + blank=True, + ) + + zendesk_ticket = models.CharField(max_length=255, blank=True, default="") + + @property + def total_requested(self): + """Return the total requested refund amount, pulled from the line items.""" + + return Decimal(0) + + def __str__(self): + """Return a reasonable string representation of the request.""" + + return ( + f"{self.status} request for order {self.order.reference_number}:" + f" {self.total_requested} requested {self.total_refunded} refunded" + ) + + +class RequestProcessingCode(TimestampedModel): + """ + Stores codes for approving/denying a request. + + These will get sent to anyone with a flag set in their profile, and this + allows a unique set of codes to be sent to each, tracked, and expired. + """ + + refund_request = models.ForeignKey( + Request, on_delete=models.CASCADE, related_name="process_code" + ) + + email = models.EmailField(help_text="The email address the code was sent to.") + + code_batch = models.UUIDField( + blank=True, + null=True, + help_text="Batch ID, generated when the codes are generated.", + ) + approve_code = models.UUIDField(default=uuid.uuid4, editable=False) + deny_code = models.UUIDField(default=uuid.uuid4, editable=False) + + code_active = models.BooleanField(default=True) + code_used = models.CharField( + choices=REFUND_CODE_TYPE_CHOICES, + max_length=20, + default="", + blank=True, + ) + code_used_on = models.DateTimeField(null=True, blank=True) + + +class RequestLine(TimestampedModel): + """Line items for a refund request.""" + + refund_request = models.ForeignKey( + Request, on_delete=models.CASCADE, related_name="lines" + ) + line = models.ForeignKey( + Line, + on_delete=models.PROTECT, + related_name="+", + help_text="The individual line item to refund.", + ) + status = models.CharField( + max_length=20, + choices=REFUND_STATUS_CHOICES, + default=REFUND_STATUS_CREATED, + blank=True, + help_text="The status of this line item.", + ) + refunded_amount = models.DecimalField( + decimal_places=5, + max_digits=20, + default=Decimal(0), + blank=True, + null=True, + help_text=( + "The amount refunded for this line item " + "(may not be the full amount charged)." + ), + ) + transactions = models.ManyToManyField( + Transaction, + related_name="refund_request_lines", + blank=True, + ) diff --git a/refunds/serializers/__init__.py b/refunds/serializers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/refunds/serializers/v0/__init__.py b/refunds/serializers/v0/__init__.py new file mode 100644 index 00000000..5e1637a8 --- /dev/null +++ b/refunds/serializers/v0/__init__.py @@ -0,0 +1,41 @@ +"""Serializers for refund requests (v0).""" + +from rest_framework import serializers + +from payments.serializers.v0 import OrderSerializer, TransactionSerializer +from refunds import models + + +class RequestLineSerializer(serializers.ModelSerializer): + """Serializer for refund request lines.""" + + transactions = TransactionSerializer(many=True) + + class Meta: + """Metadata for the serializer.""" + + model = models.RequestLine + fields = "__all__" + read_only_fields = ( + "status", + "refund_amount", + ) + + +class RequestSerializer(serializers.ModelSerializer): + """Serializer for refund requests.""" + + lines = RequestLineSerializer(many=True) + order = OrderSerializer() + + class Meta: + """Metadata for the serializer.""" + + model = models.Request + fields = "__all__" + read_only_fields = ( + "processed_date", + "processed_by", + "total_refunded", + "status", + ) diff --git a/refunds/views/__init__.py b/refunds/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/refunds/views/v0/__init__.py b/refunds/views/v0/__init__.py new file mode 100644 index 00000000..78d17822 --- /dev/null +++ b/refunds/views/v0/__init__.py @@ -0,0 +1 @@ +"""Views for refund requests (api v0).""" diff --git a/unified_ecommerce/constants.py b/unified_ecommerce/constants.py index ed7acd71..a2c9a8ba 100644 --- a/unified_ecommerce/constants.py +++ b/unified_ecommerce/constants.py @@ -251,3 +251,11 @@ REFUND_STATUS_FAILED, ] REFUND_STATUS_CHOICES = list(zip(REFUND_STATUSES, REFUND_STATUSES)) + +REFUND_CODE_TYPE_APPROVE = "approve" +REFUND_CODE_TYPE_DENY = "deny" +REFUND_CODE_TYPES = [ + REFUND_CODE_TYPE_APPROVE, + REFUND_CODE_TYPE_DENY, +] +REFUND_CODE_TYPE_CHOICES = list(zip(REFUND_CODE_TYPES, REFUND_CODE_TYPES)) diff --git a/unified_ecommerce/settings.py b/unified_ecommerce/settings.py index 14c169c7..76c1fec8 100644 --- a/unified_ecommerce/settings.py +++ b/unified_ecommerce/settings.py @@ -104,6 +104,7 @@ "cart", "mitol.payment_gateway.apps.PaymentGatewayApp", "openapi", + "refunds", ] MIDDLEWARE = [