Skip to content

Commit

Permalink
WIP: adding first run at approve/deny logic - not at all done yet
Browse files Browse the repository at this point in the history
  • Loading branch information
jkachel committed Feb 11, 2025
1 parent 94cb2ed commit ce6f89b
Show file tree
Hide file tree
Showing 5 changed files with 169 additions and 1 deletion.
9 changes: 9 additions & 0 deletions refunds/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Custom exceptions for refunds."""

class RefundAlreadyCompleteError(Exception):
"""Raised when a refund has already been completed."""

def __init__(self, *args: object) -> None:
"""Initialize the exception."""

super().__init__(*args)
51 changes: 51 additions & 0 deletions refunds/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@
from mitol.common.models import TimestampedModel

from payments.models import Line, Order, Transaction
from refunds.exceptions import RefundAlreadyCompleteError, RefundOrderImproperStateError
from system_meta.models import IntegratedSystem
from unified_ecommerce.constants import (
REFUND_CODE_TYPE_CHOICES,
REFUND_STATUS_APPROVED,
REFUND_STATUS_CHOICES,
REFUND_STATUS_CREATED,
REFUND_STATUS_DENIED,
REFUND_STATUSES_PROCESSABLE,
)
from unified_ecommerce.plugin_manager import get_plugin_manager

Expand Down Expand Up @@ -90,6 +94,10 @@ class Request(TimestampedModel):
)

zendesk_ticket = models.CharField(max_length=255, blank=True, default="")
refund_reason = models.TextField(blank=True,
default="",
help_text="Reason for refund, supplied by the processing user."
)

@property
def total_requested(self):
Expand All @@ -103,6 +111,48 @@ def total_approved(self):

return Decimal(sum(line.refunded_amount for line in self.lines.all()))

def _check_status_prerequisites(self):
"""
Check the request and the order before refunding it.
Regardless of whether this is an approval or a denial, we should check
if the order is in a proper state to be refunded, and whether or not
the request itself is in a proper state for processing.
"""

if self.status not in REFUND_STATUSES_PROCESSABLE:
msg = f"Request {self} must be in a processable state to process."
raise RefundAlreadyCompleteError(msg)

if self.order.state != Order.STATE.FULFILLED:
msg = (f"Order {self.order.reference_number} must be in fulfilled "
"state to process.")
raise RefundOrderImproperStateError(msg)

def approve(self, reason: str, *, lines: list|None = None):
"""Approve the request."""

self._check_status_prerequisites()

self.refund_reason = reason
self.save()

for line in (lines or self.lines.all()): # note: this ain't gonna work but it's EOD so here's a note for tomorrow
self.lines.filter(pk=line["line"]).update(
status=REFUND_STATUS_APPROVED,
refunded_amount=line["refunded_amount"],
)


def deny(self, reason: str):
"""Deny the request."""

self._check_status_prerequisites()

self.status = REFUND_STATUS_DENIED
self.refund_reason = reason
self.save()

def __str__(self):
"""Return a reasonable string representation of the request."""

Expand All @@ -113,6 +163,7 @@ def __str__(self):


class RequestProcessingCode(TimestampedModel):

"""
Stores codes for approving/denying a request.
Expand Down
21 changes: 21 additions & 0 deletions refunds/serializers/v0/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,29 @@ class Meta:
)


# API Request Serializers


class CreateFromOrderApiSerializer(serializers.Serializer):
"""Serializer for the create from order API."""

order = serializers.IntegerField()
lines = serializers.ListField(child=serializers.IntegerField(), allow_empty=True)


class ProcessRequestCodeLineSerializer(serializers.Serializer):
"""Serializer for line items for a refund request."""

line = serializers.IntegerField()
refunded_amount = serializers.DecimalField(
max_digits=20, decimal_places=5, min_value=0
)


class ProcessRequestCodeSerializer(serializers.Serializer):
"""Serializer for the process request code API."""

email = serializers.EmailField()
code = serializers.CharField()
reason = serializers.CharField(allow_empty=True)
lines = ProcessRequestCodeLineSerializer(many=True)
85 changes: 84 additions & 1 deletion refunds/views/v0/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
"""Views for refund requests (api v0)."""

import logging

from django.db import transaction
from django.db.models import Q
from drf_spectacular.utils import (
extend_schema,
inline_serializer,
)
from rest_framework import status, viewsets
from rest_framework.decorators import api_view, permission_classes
Expand All @@ -10,13 +15,16 @@

from payments.models import Order
from refunds.api import create_request_from_order
from refunds.models import RequestLine
from refunds.models import RequestLine, RequestProcessingCode
from refunds.serializers.v0 import (
CreateFromOrderApiSerializer,
ProcessRequestCodeSerializer,
RequestLineSerializer,
RequestSerializer,
)

log = logging.getLogger(__name__)


class RefundRequestViewSet(viewsets.ModelViewSet):
"""API endpoint for refund requests."""
Expand Down Expand Up @@ -75,3 +83,78 @@ def create_from_order(request):
new_request = create_request_from_order(request.user, order, lines=lines)

return Response(RequestSerializer(new_request).data, status=status.HTTP_201_CREATED)


@extend_schema(
description=(
"Process a refund based on the code provided."
),
methods=["POST"],
request=ProcessRequestCodeSerializer,
responses={
201: RequestSerializer,
401: inline_serializer(fields={"error": str}),
},
)
@api_view(["POST"])
@permission_classes(
[
IsAuthenticated,
]
)
def accept_code(request):
"""
Process a request code.
The codes themselves are sent to the request processing recipient via email,
embedded in links to the frontend. The frontend presents the user with a
form that requests their email address, an optional reason for refund, and
a selector for the lines they wish to refund. This is the API that processes
that request. (Users don't directly go to this endpoint.)
"""

code = request.data.get("code", None)
email = request.data.get("email", None)
reason = request.data.get("reason", None)
lines = request.data.get("lines", [])

if not code or not email:
return Response(
{ "error": "Provide a code and email." },
status=status.HTTP_400_BAD_REQUEST
)

try:
with transaction.atomic():
code = RequestProcessingCode.objects.filter(
Q(approve_code=code) | Q(deny_code=code)
).filter(email=email).filter(code_active=True).get()

# If this pulled up OK, then immediately invalidate all the other
# codes.
RequestProcessingCode.objects.filter(refund_request=code.refund_request).update(
code_active=False
)
except RequestProcessingCode.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)

if code.approve_code == code:
# do something to approve the request
try:
code.refund_request.approve(reason, lines=lines)
except ValueError as e:
log.exception(
"Refund failed: Attempted to accept code %s for completed request %s",
code,
code.refund_request,
)
return Response(
{ "error": str(e) },
status=status.HTTP_400_BAD_REQUEST
)

if code.deny_code == code:
# do something to deny the request
pass

return Response(status=status.HTTP_200_OK)
4 changes: 4 additions & 0 deletions unified_ecommerce/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,10 @@
REFUND_STATUS_FAILED,
]
REFUND_STATUS_CHOICES = list(zip(REFUND_STATUSES, REFUND_STATUSES))
REFUND_STATUSES_PROCESSABLE = [
REFUND_STATUS_PENDING,
REFUND_STATUS_FAILED,
]

REFUND_CODE_TYPE_APPROVE = "approve"
REFUND_CODE_TYPE_DENY = "deny"
Expand Down

0 comments on commit ce6f89b

Please sign in to comment.