diff --git a/develop/core/tests/test_api.py b/develop/core/tests/test_api.py index ed368ac5..74c2ae87 100644 --- a/develop/core/tests/test_api.py +++ b/develop/core/tests/test_api.py @@ -1,17 +1,14 @@ import json -import re from datetime import timedelta - +from decimal import Decimal from django.contrib.auth import get_user_model -from django.http.response import Http404 from django.test import TestCase, Client from django.urls import reverse from django.utils import timezone from unittest import skipIf from vendor.forms import DateTimeRangeForm -from vendor.processors.base import PaymentProcessorBase -from vendor.models import Offer, Price, Receipt, Subscription +from vendor.models import Offer, Price, Subscription, Payment User = get_user_model() @@ -46,6 +43,75 @@ def test_subscription_price_update_fail(self): self.assertEqual(response.status_code, 404) + def test_refund_payment_success(self): + payment = Payment.objects.get(pk=1) + url = reverse('vendor_api:refund-payment-api', kwargs={"uuid": payment.uuid}) + + form_data = { + "refund_amount": 400, + "reason": "duplicate", + } + + response = self.client.post(url, form_data) + + self.assertEquals(json.loads(response.content)['message'], "Payment Refunded") + + def test_refund_payment_fail(self): + payment = Payment.objects.get(pk=1) + url = reverse('vendor_api:refund-payment-api', kwargs={"uuid": payment.uuid}) + + form_data = { + "refund_amount": 9000, + "reason": "duplicate", + } + + response = self.client.post(url, form_data) + self.assertIn('refund_amount', json.loads(response.content)['error']) + + def test_partial_refund_payment_success(self): + payment = Payment.objects.get(pk=1) + url = reverse('vendor_api:refund-payment-api', kwargs={"uuid": payment.uuid}) + + form_data = { + "refund_amount": 200, + "reason": "duplicate", + } + + response = self.client.post(url, form_data) + self.assertEquals(json.loads(response.content)['message'], "Payment Refunded") + + response = self.client.post(url, form_data) + self.assertEquals(json.loads(response.content)['message'], "Payment Refunded") + + payment.refresh_from_db() + + self.assertEqual(sum(Decimal(refund['amount']) for refund in payment.result['refunds']), form_data["refund_amount"] * 2) + + def test_partial_refund_payment_fail(self): + payment = Payment.objects.get(pk=1) + url = reverse('vendor_api:refund-payment-api', kwargs={"uuid": payment.uuid}) + + form_data = { + "refund_amount": 200, + "reason": "duplicate", + } + + response = self.client.post(url, form_data) + self.assertEquals(json.loads(response.content)['message'], "Payment Refunded") + + form_data['refund_amount'] = 900 + response = self.client.post(url, form_data) + self.assertIn('refund_amount', json.loads(response.content)['error']) + + def test_get_payment_refund_form(self): + payment = Payment.objects.get(pk=1) + url = reverse('vendor_api:refund-payment-api', kwargs={"uuid": payment.uuid}) + + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + + @skipIf(True, "Webhook tests are highly dependent on data in Authroizenet and local data.") class AuthorizeNetAPITest(TestCase): diff --git a/pyproject.toml b/pyproject.toml index b45cc241..f1528131 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ universal = true [project] name = "django-vendor" -version = "0.4.14" +version = "0.4.15" authors = [ { name="Grant Viklund", email="renderbox@gmail.com" }, diff --git a/src/vendor/api/v1/urls.py b/src/vendor/api/v1/urls.py index f49e6b0c..b3b1d173 100644 --- a/src/vendor/api/v1/urls.py +++ b/src/vendor/api/v1/urls.py @@ -16,6 +16,7 @@ path('profile//offer//add', api_views.AddOfferToProfileView.as_view(), name="manager-profile-add-offer"), path('product//availability', api_views.ProductAvailabilityToggleView.as_view(), name="manager-product-availablility"), path('subscription/price/update', api_views.SubscriptionPriceUpdateView.as_view(), name="manager-subscription-price-update"), + path('refund-payment//', api_views.RefundPaymentAPIView.as_view(), name="refund-payment-api"), # AuthorizeNet path('authorizenet/authcapture', authorizenet_views.AuthorizeCaptureAPI.as_view(), name='api-authorizenet-authcapture'), path('authorizenet/void', authorizenet_views.VoidAPI.as_view(), name='api-authorizenet-void'), diff --git a/src/vendor/api/v1/views.py b/src/vendor/api/v1/views.py index 3a58c7f8..b712e316 100644 --- a/src/vendor/api/v1/views.py +++ b/src/vendor/api/v1/views.py @@ -2,15 +2,17 @@ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.core.exceptions import ObjectDoesNotExist -from django.http import HttpResponse +from django.forms import BaseModelForm +from django.http import HttpResponse, JsonResponse from django.shortcuts import redirect, get_object_or_404 from django.urls import reverse_lazy, reverse from django.utils.translation import gettext_lazy as _ -from django.utils import timezone from django.views import View +from django.views.generic.edit import BaseUpdateView from vendor.config import VENDOR_PRODUCT_MODEL -from vendor.models import CustomerProfile, Invoice, Offer, Receipt, Subscription +from vendor.forms import PaymentRefundForm +from vendor.models import CustomerProfile, Payment, Offer, Receipt, Subscription from vendor.models.choice import InvoiceStatus from vendor.processors import get_site_payment_processor from vendor.utils import get_or_create_session_cart, get_site_from_request @@ -232,4 +234,39 @@ class RenewSubscription(LoginRequiredMixin, View): def post(self, request, *args, **kwargs): # TODO: Need to implement - pass \ No newline at end of file + pass + + +class RefundPaymentAPIView(LoginRequiredMixin, BaseUpdateView): + form_class = PaymentRefundForm + success_url = reverse_lazy("vendor:customer-subscriptions") + + def get_object(self): + return Payment.objects.get( + uuid=self.kwargs.get("uuid"), + invoice__site=get_site_from_request(self.request), + ) + + def get(self, request, *args, **kwargs): + payment = self.get_object() + refund_form = self.get_form_class()(instance=payment) + + return JsonResponse(refund_form.data) + + def form_valid(self, form): + processor = get_site_payment_processor(form.instance.invoice.site)( + form.instance.invoice.site + ) + + try: + processor.refund_payment(form) + if not processor.transaction_succeeded: + return JsonResponse({"error": processor.transaction_info}) + + except Exception as exc: + return JsonResponse({"error": str(exc)}) + + return JsonResponse({"message": _("Payment Refunded")}) + + def form_invalid(self, form: BaseModelForm): + return JsonResponse({"error": form.errors}) diff --git a/src/vendor/forms.py b/src/vendor/forms.py index b57b3a81..03f26058 100644 --- a/src/vendor/forms.py +++ b/src/vendor/forms.py @@ -1,5 +1,6 @@ from calendar import monthrange from datetime import datetime +from decimal import Decimal from django import forms from django.apps import apps from django.conf import settings @@ -11,12 +12,26 @@ from integrations.models import Credential from vendor.config import VENDOR_PRODUCT_MODEL -from vendor.models import Address, Offer, Price, offer_term_details_default, CustomerProfile, Payment, Subscription -from vendor.models.choice import PaymentTypes, TermType, Country, USAStateChoices, SubscriptionStatus +from vendor.models import ( + Address, + Offer, + Price, + offer_term_details_default, + CustomerProfile, + Payment, +) +from vendor.models.choice import ( + PaymentTypes, + RefundReasons, + TermType, + Country, + USAStateChoices, + SubscriptionStatus, +) from vendor.utils import get_site_from_request +from vendor.config import SiteSelectForm -from vendor.config import SiteSelectForm Product = apps.get_model(VENDOR_PRODUCT_MODEL) COUNTRY_CHOICE = getattr(settings, 'VENDOR_COUNTRY_CHOICE', Country) @@ -297,6 +312,32 @@ class PaymentFrom(forms.Form): payment_type = forms.ChoiceField(label=_("Payment Type"), choices=PaymentTypes.choices, widget=forms.widgets.HiddenInput) +class PaymentRefundForm(forms.ModelForm): + refund_amount = forms.DecimalField() + reason = forms.ChoiceField(choices=RefundReasons.choices) + void_end_date = forms.BooleanField(required=False) + + class Meta: + model = Payment + fields = ['refund_amount', 'reason', 'void_end_date'] + + def clean_refund_amount(self): + past_refunds_amount = 0 + refund_amount = self.cleaned_data.get('refund_amount', 0) + + if refund_amount > self.instance.amount: + raise forms.ValidationError(_("Refund amount cannot be greater than the original amount")) + + if (past_refunds := self.instance.result.get('refunds', [])): + for partial_refund in past_refunds: + past_refunds_amount += Decimal(partial_refund.get('amount', 0)) + + if (refund_amount + past_refunds_amount) > self.instance.amount: + raise forms.ValidationError(_("Refund amount cannot be greater than the original amount")) + + return refund_amount + + class CreditCardForm(PaymentFrom): full_name = forms.CharField(required=True, label=_("Name on Card"), max_length=80) card_number = CreditCardField(label=_("Credit Card Number"), placeholder=u'0000 0000 0000 0000', min_length=12, max_length=19) diff --git a/src/vendor/models/choice.py b/src/vendor/models/choice.py index 90561952..0f7f4f27 100644 --- a/src/vendor/models/choice.py +++ b/src/vendor/models/choice.py @@ -26,6 +26,13 @@ class TermType(models.IntegerChoices): ONE_TIME_USE = 220, _("One-Time Use") +class RefundReasons(models.TextChoices): + DUPLICATE = "duplicate", _("Duplicate") + FRAUDULENT = "fraudulent", _("Fraudulent") + CUSTOMER_REQUEST = "requested_by_customer", _("Requested by Customer") + OTHER = "other", _("Other") + + class PurchaseStatus(models.IntegerChoices): QUEUED = 1, _("Queued") ACTIVE = 2, _("Active") diff --git a/src/vendor/models/payment.py b/src/vendor/models/payment.py index f3f2ff48..e30a93a0 100644 --- a/src/vendor/models/payment.py +++ b/src/vendor/models/payment.py @@ -1,15 +1,16 @@ import uuid +from decimal import Decimal from django.db.models.aggregates import Sum -from django.db.models import Count from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist from django.db import models +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from vendor.models.receipt import Receipt from vendor.models.subscription import Subscription from vendor.models.base import SoftDeleteModelBase -from vendor.models.choice import PurchaseStatus +from vendor.models.choice import PurchaseStatus, RefundReasons from vendor.utils import get_display_decimal @@ -145,3 +146,44 @@ def get_receipt(self): def get_amount_display(self): return get_display_decimal(self.amount) + + def record_refund(self, amount, date=timezone.now(), reason=RefundReasons.OTHER.value): + self.status = PurchaseStatus.REFUNDED + if "refunds" not in self.result: + self.result["refunds"] = [] + + self.result["refunds"].append({ + "date": date.isoformat(), + "reason": reason, + "amount": str(amount) + }) + + self.save() + + def is_refund_available(self, refund_amount): + past_refunds = 0 + past_refund_amount = 0 + + if refund_amount > self.amount: + return False + + if (past_refunds := self.result.get("refunds", [])): + past_refund_amount = sum([ + Decimal(past_refund.get("amount", 0)) + for past_refund in past_refunds + ]) + + if (past_refund_amount + refund_amount) > self.amount: + return False + + return True + + def get_past_refunds(self): + past_refunds = [] + reasons = {reason[0]: reason[1] for reason in RefundReasons.choices} + + for refund in self.result.get("refunds", []): + refund['reason'] = reasons[refund['reason'].lower()] + past_refunds.append(refund) + + return past_refunds \ No newline at end of file diff --git a/src/vendor/processors/base.py b/src/vendor/processors/base.py index 3b10483d..fd99cb80 100644 --- a/src/vendor/processors/base.py +++ b/src/vendor/processors/base.py @@ -572,8 +572,15 @@ def subscription_update_price(self, subscription, new_price, user): # ------------------- # Refund a Payment - def refund_payment(self): - ... + def refund_payment(self, refund_form, date=timezone.now()): + refund_form.instance.record_refund(refund_form.cleaned_data['refund_amount'], date, refund_form.cleaned_data['reason']) + + if refund_form.cleaned_data['void_end_date']: + receipt = refund_form.instance.get_receipt() + receipt.end_date = date + receipt.save() + + self.transaction_succeeded = True def subscription_payment_failed(self, subscription, transaction_id): self.payment = Payment.objects.create( diff --git a/src/vendor/processors/stripe.py b/src/vendor/processors/stripe.py index 42f276ce..3e1d0b78 100644 --- a/src/vendor/processors/stripe.py +++ b/src/vendor/processors/stripe.py @@ -684,6 +684,16 @@ def build_invoice(self, currency=DEFAULT_CURRENCY): 'on_behalf_of': self.get_stripe_connect_account() } + def build_refund(self, refund_form): + int_amount = self.convert_decimal_to_integer(refund_form.cleaned_data["refund_amount"]) + return { + "charge": refund_form.instance.transaction, + "amount": int_amount, + "reverse_transfer": True, + "refund_application_fee": True, + "reason": refund_form.cleaned_data['reason'] + } + ########## # Customers ########## @@ -1266,7 +1276,6 @@ def get_offers_from_invoice_line_items(self, stripe_line_items): ########## # Payments and Receipts ########## - def get_or_create_payment_from_stripe_payment_and_charge(self, invoice, stripe_payment_method, stripe_charge): payment = None created = False @@ -1935,3 +1944,11 @@ def subscription_update_payment(self, subscription): payment_info['account_type'] = account_type subscription.save_payment_info(payment_info) + + def refund_payment(self, refund_form, date=timezone.now()): + refund_data = self.build_refund(refund_form) + + stripe_refund = self.stripe_create_object(self.stripe.Refund, refund_data) + + if stripe_refund: + super().refund_payment(refund_form, date=timezone.datetime.fromtimestamp(stripe_refund.created, tz=timezone.utc))