Skip to content

Commit

Permalink
Merge pull request #453 from renderbox/develop
Browse files Browse the repository at this point in the history
Release v0.4.15
  • Loading branch information
rhimmelbauer authored May 28, 2024
2 parents 4297c67 + 42b9115 commit 5d17e3f
Show file tree
Hide file tree
Showing 9 changed files with 236 additions and 18 deletions.
76 changes: 71 additions & 5 deletions develop/core/tests/test_api.py
Original file line number Diff line number Diff line change
@@ -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()

Expand Down Expand Up @@ -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):

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ universal = true

[project]
name = "django-vendor"
version = "0.4.14"
version = "0.4.15"

authors = [
{ name="Grant Viklund", email="[email protected]" },
Expand Down
1 change: 1 addition & 0 deletions src/vendor/api/v1/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
path('profile/<uuid:uuid_profile>/offer/<uuid:uuid_offer>/add', api_views.AddOfferToProfileView.as_view(), name="manager-profile-add-offer"),
path('product/<uuid:uuid>/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/<uuid:uuid>/', 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'),
Expand Down
45 changes: 41 additions & 4 deletions src/vendor/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -232,4 +234,39 @@ class RenewSubscription(LoginRequiredMixin, View):

def post(self, request, *args, **kwargs):
# TODO: Need to implement
pass
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})
47 changes: 44 additions & 3 deletions src/vendor/forms.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions src/vendor/models/choice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
46 changes: 44 additions & 2 deletions src/vendor/models/payment.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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
11 changes: 9 additions & 2 deletions src/vendor/processors/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
19 changes: 18 additions & 1 deletion src/vendor/processors/stripe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
##########
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))

0 comments on commit 5d17e3f

Please sign in to comment.