From c87e737222b8c0c90fdecc2da7c45c330550cd63 Mon Sep 17 00:00:00 2001 From: Jerome Celle Date: Thu, 21 Nov 2019 20:39:26 +0100 Subject: [PATCH] Refactor make refund --- retirement/models.py | 50 +++++- retirement/serializers.py | 9 +- retirement/tests/tests_model_Reservation.py | 153 ++++++++++++++++-- .../tests/tests_viewset_Reservation_update.py | 66 ++++---- retirement/views.py | 33 +--- 5 files changed, 228 insertions(+), 83 deletions(-) diff --git a/retirement/models.py b/retirement/models.py index 3fe245f1..b0daabb6 100644 --- a/retirement/models.py +++ b/retirement/models.py @@ -4,14 +4,11 @@ import traceback from datetime import timedelta -from django.conf import settings - import requests from django.conf import settings from django.core.mail import mail_admins, send_mail from django.template.loader import render_to_string from django.utils import timezone -from rest_framework.reverse import reverse from blitz_api.models import Address from django.contrib.auth import get_user_model @@ -22,10 +19,12 @@ from simple_history.models import HistoricalRecords from log_management.models import Log -from store.models import Membership, OrderLine, BaseProduct,\ - Coupon +from store.models import Membership, OrderLine, BaseProduct, \ + Coupon, Refund +from store.services import refund_amount User = get_user_model() +TAX_RATE = settings.LOCAL_SETTINGS['SELLING_TAX'] class Retreat(Address, SafeDeleteModel, BaseProduct): @@ -498,6 +497,47 @@ class Reservation(SafeDeleteModel): def __str__(self): return str(self.user) + def get_refund_value(self, total_refund=False): + # First get net pay: total cost + refund_value = float(self.order_line.cost) + # Add the tax rate, so we have the real value pay by the user + refund_value *= TAX_RATE + 1.0 + + if not total_refund: + # keep only the part that the retreat allow to refund + refund_value *= self.retreat.refund_rate / 100 + + # Remove value already refund + previous_refunds = self.order_line.refunds + if previous_refunds: + refund_value -= sum( + previous_refunds.all().values_list('amount', flat=True) + ) + + return round(refund_value, 2) if refund_value > 0 else 0 + + def make_refund(self, refund_reason, total_refund=False): + + amount_to_refund = self.get_refund_value(total_refund) + + # paysafe use value without cent + amount_to_refund_paysafe = int(round(amount_to_refund * 100)) + + refund_response = refund_amount( + self.order_line.order.settlement_id, + amount_to_refund_paysafe + ) + refund_res_content = refund_response.json() + + refund = Refund.objects.create( + orderline=self.order_line, + refund_date=timezone.now(), + amount=amount_to_refund, + details=refund_reason, + refund_id=refund_res_content['id'], + ) + return refund + class WaitQueue(models.Model): """ diff --git a/retirement/serializers.py b/retirement/serializers.py index 99482fc3..23598dd6 100644 --- a/retirement/serializers.py +++ b/retirement/serializers.py @@ -1,6 +1,5 @@ -from copy import copy from datetime import timedelta -from decimal import Decimal, DecimalException +from decimal import Decimal import json import requests import traceback @@ -11,11 +10,10 @@ from django.core.mail import mail_admins from django.core.mail import send_mail from django.db import transaction -from django.db.models import F from django.template.loader import render_to_string from django.utils import timezone from django.utils.translation import ugettext_lazy as _ -from rest_framework import serializers, status +from rest_framework import serializers from rest_framework.reverse import reverse from rest_framework.validators import UniqueValidator @@ -23,13 +21,13 @@ remove_translation_fields, getMessageTranslate) from log_management.models import Log +from retirement.services import refund_retreat from store.exceptions import PaymentAPIError from store.models import Order, OrderLine, PaymentProfile, Refund from store.serializers import BaseProductSerializer, CouponSerializer from store.services import (charge_payment, create_external_payment_profile, create_external_card, - get_external_cards, PAYSAFE_CARD_TYPE, PAYSAFE_EXCEPTION, refund_amount, ) @@ -37,7 +35,6 @@ from .fields import TimezoneField from .models import (Picture, Reservation, Retreat, WaitQueue, WaitQueueNotification, RetreatInvitation) -from .services import refund_retreat User = get_user_model() diff --git a/retirement/tests/tests_model_Reservation.py b/retirement/tests/tests_model_Reservation.py index df978322..dc79c274 100644 --- a/retirement/tests/tests_model_Reservation.py +++ b/retirement/tests/tests_model_Reservation.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime import pytz from django.conf import settings @@ -8,20 +8,21 @@ from blitz_api.factories import UserFactory -from store.models import Order, OrderLine +from store.models import Order, OrderLine, Coupon from ..models import Reservation, Retreat LOCAL_TIMEZONE = pytz.timezone(settings.TIME_ZONE) +TAX_RATE = settings.LOCAL_SETTINGS['SELLING_TAX'] + class ReservationTests(APITestCase): - @classmethod - def setUpClass(cls): - super(ReservationTests, cls).setUpClass() - cls.user = UserFactory() - cls.retreat_type = ContentType.objects.get_for_model(Retreat) - cls.retreat = Retreat.objects.create( + + def setUp(self): + self.user = UserFactory() + self.retreat_type = ContentType.objects.get_for_model(Retreat) + self.retreat = Retreat.objects.create( name="random_retreat", details="This is a description of the retreat.", seats=40, @@ -42,16 +43,16 @@ def setUpClass(cls): review_url='example3.com', has_shared_rooms=True ) - cls.order = Order.objects.create( - user=cls.user, + self.order = Order.objects.create( + user=self.user, transaction_date=timezone.now(), authorization_id=1, settlement_id=1, ) - cls.order_line = OrderLine.objects.create( - order=cls.order, + self.order_line = OrderLine.objects.create( + order=self.order, quantity=999, - content_type=cls.retreat_type, + content_type=self.retreat_type, object_id=1, ) @@ -67,3 +68,129 @@ def test_create(self): ) self.assertEqual(str(reservation), str(self.user)) + + def test_refund_value_with_coupon(self): + + retreat = Retreat.objects.create( + name="random_retreat", + details="This is a description of the retreat.", + seats=40, + address_line1="123 random street", + postal_code="123 456", + state_province="Random state", + country="Random country", + price=100, + start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), + end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), + min_day_refund=7, + min_day_exchange=7, + refund_rate=90, + is_active=True, + accessibility=True, + form_url="example.com", + carpool_url='example2.com', + review_url='example3.com', + has_shared_rooms=True + ) + + order = Order.objects.create( + user=self.user, + transaction_date=timezone.now(), + authorization_id=1, + settlement_id=1, + ) + + coupon = Coupon.objects.create( + value=20, + code="ASD1234E", + start_time="2019-01-06T15:11:05-05:00", + end_time="2020-01-06T15:11:06-05:00", + max_use=100, + max_use_per_user=2, + details="detail", + owner=self.user, + ) + + order_line = OrderLine.objects.create( + order=order, + quantity=999, + content_type=self.retreat_type, + object_id=1, + cost=80, + coupon_real_value=20, + coupon=coupon + ) + + reservation = Reservation.objects.create( + user=self.user, + retreat=retreat, + order_line=order_line, + is_active=True, + ) + + refund_value = reservation.get_refund_value() + + self.assertEqual(refund_value, round(72 * (TAX_RATE + 1.0), 2)) + + def test_refund_value_100(self): + + retreat = Retreat.objects.create( + name="random_retreat", + details="This is a description of the retreat.", + seats=40, + address_line1="123 random street", + postal_code="123 456", + state_province="Random state", + country="Random country", + price=100, + start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), + end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), + min_day_refund=7, + min_day_exchange=7, + refund_rate=100, + is_active=True, + accessibility=True, + form_url="example.com", + carpool_url='example2.com', + review_url='example3.com', + has_shared_rooms=True + ) + + order = Order.objects.create( + user=self.user, + transaction_date=timezone.now(), + authorization_id=1, + settlement_id=1, + ) + + coupon = Coupon.objects.create( + value=20, + code="ASD1234E", + start_time="2019-01-06T15:11:05-05:00", + end_time="2020-01-06T15:11:06-05:00", + max_use=100, + max_use_per_user=2, + details="detail", + owner=self.user, + ) + + order_line = OrderLine.objects.create( + order=order, + quantity=999, + content_type=self.retreat_type, + object_id=1, + cost=80, + coupon_real_value=20, + coupon=coupon + ) + + reservation = Reservation.objects.create( + user=self.user, + retreat=retreat, + order_line=order_line, + is_active=True, + ) + + refund_value = reservation.get_refund_value() + + self.assertEqual(refund_value, round(80 * (TAX_RATE + 1.0), 2)) diff --git a/retirement/tests/tests_viewset_Reservation_update.py b/retirement/tests/tests_viewset_Reservation_update.py index 1cee7e84..5decdf37 100644 --- a/retirement/tests/tests_viewset_Reservation_update.py +++ b/retirement/tests/tests_viewset_Reservation_update.py @@ -49,14 +49,12 @@ ) class ReservationTests(APITestCase): - @classmethod - def setUpClass(cls): - super(ReservationTests, cls).setUpClass() - cls.client = APIClient() - cls.user = UserFactory() - cls.admin = AdminFactory() - cls.retreat_type = ContentType.objects.get_for_model(Retreat) - cls.retreat = Retreat.objects.create( + def setUp(self): + self.client = APIClient() + self.user = UserFactory() + self.admin = AdminFactory() + self.retreat_type = ContentType.objects.get_for_model(Retreat) + self.retreat = Retreat.objects.create( name="mega_retreat", details="This is a description of the mega retreat.", seats=400, @@ -78,7 +76,7 @@ def setUpClass(cls): review_url='example3.com', has_shared_rooms=True, ) - cls.retreat2 = Retreat.objects.create( + self.retreat2 = Retreat.objects.create( name="random_retreat", details="This is a description of the retreat.", seats=40, @@ -99,7 +97,7 @@ def setUpClass(cls): review_url='example3.com', has_shared_rooms=True, ) - cls.retreat_overlap = Retreat.objects.create( + self.retreat_overlap = Retreat.objects.create( name="ultra_retreat", details="This is a description of the ultra retreat.", seats=400, @@ -120,37 +118,37 @@ def setUpClass(cls): review_url='example3.com', has_shared_rooms=True, ) - cls.order = Order.objects.create( - user=cls.user, + self.order = Order.objects.create( + user=self.user, transaction_date=timezone.now(), authorization_id=1, settlement_id=1, ) - cls.order_line = OrderLine.objects.create( - order=cls.order, + self.order_line = OrderLine.objects.create( + order=self.order, quantity=1, - content_type=cls.retreat_type, - object_id=cls.retreat.id, - cost=cls.retreat.price, - ) - cls.reservation = Reservation.objects.create( - user=cls.user, - retreat=cls.retreat, - order_line=cls.order_line, + content_type=self.retreat_type, + object_id=self.retreat.id, + cost=self.retreat.price, + ) + self.reservation = Reservation.objects.create( + user=self.user, + retreat=self.retreat, + order_line=self.order_line, is_active=True, ) - cls.reservation_expected_payload = { - 'id': cls.reservation.id, + self.reservation_expected_payload = { + 'id': self.reservation.id, 'is_active': True, 'is_present': False, 'retreat': 'http://testserver/retreat/retreats/' + - str(cls.reservation.retreat.id), + str(self.reservation.retreat.id), 'url': 'http://testserver/retreat/reservations/' + - str(cls.reservation.id), + str(self.reservation.id), 'user': 'http://testserver/users/' + - str(cls.user.id), + str(self.user.id), 'order_line': 'http://testserver/order_lines/' + - str(cls.order_line.id), + str(self.order_line.id), 'cancelation_date': None, 'cancelation_action': None, 'cancelation_reason': None, @@ -158,10 +156,10 @@ def setUpClass(cls): 'exchangeable': True, 'invitation': None, } - cls.reservation_non_exchangeable = Reservation.objects.create( - user=cls.admin, - retreat=cls.retreat, - order_line=cls.order_line, + self.reservation_non_exchangeable = Reservation.objects.create( + user=self.admin, + retreat=self.retreat, + order_line=self.order_line, is_active=True, exchangeable=False, ) @@ -663,6 +661,8 @@ def test_update_partial_more_expensive_retreat(self): self.assertEqual(new_order.transaction_date, FIXED_TIME) self.assertEqual(new_order.user, self.user) + previous_order_line = self.reservation.order_line + # Validate the new orderline self.reservation.refresh_from_db() new_orderline = self.reservation.order_line @@ -795,6 +795,8 @@ def test_update_partial_more_expensive_retreat_single_use_token(self): self.assertEqual(new_order.transaction_date, FIXED_TIME) self.assertEqual(new_order.user, self.user) + previous_order_line = self.reservation.order_line + # Validate the new orderline self.reservation.refresh_from_db() new_orderline = self.reservation.order_line diff --git a/retirement/views.py b/retirement/views.py index 92f578ef..ed3931e2 100644 --- a/retirement/views.py +++ b/retirement/views.py @@ -24,8 +24,8 @@ from blitz_api.serializers import ExportMediaSerializer from log_management.models import Log from store.exceptions import PaymentAPIError -from store.models import Refund, OptionProduct, OrderLineBaseProduct -from store.services import refund_amount, PAYSAFE_EXCEPTION +from store.models import OrderLineBaseProduct +from store.services import PAYSAFE_EXCEPTION from . import permissions, serializers from .models import (Picture, Reservation, Retreat, WaitQueue, @@ -384,28 +384,7 @@ def destroy(self, request, *args, **kwargs): }) if respects_minimum_days and refundable: try: - amount = retreat.price - # The refund_rate converts in cents at the same time - amount_no_tax = Decimal( - amount * retreat.refund_rate - ) - amount_tax = Decimal(TAX) * amount_no_tax - total_amount = round(Decimal( - amount_no_tax + amount_tax - ), 2) - refund_instance = Refund.objects.create( - orderline=order_line, - refund_date=timezone.now(), - amount=total_amount / 100, - details="Reservation canceled", - ) - refund_response = refund_amount( - order.settlement_id, - int(round(total_amount)) - ) - refund_res_content = refund_response.json() - refund_instance.refund_id = refund_res_content['id'] - refund_instance.save() + refund = instance.make_refund("Reservation canceled") except PaymentAPIError as err: if str(err) == PAYSAFE_EXCEPTION['3406']: raise rest_framework_serializers.ValidationError({ @@ -452,12 +431,12 @@ def destroy(self, request, *args, **kwargs): # Send an email if a refund has been issued if reservation_active and instance.cancelation_action == 'R': self.send_refund_confirmation_email( - amount=amount, + amount=round(refund.amount - refund.amount * Decimal(TAX)), retreat=retreat, order=order, user=user, - total_amount=total_amount, - amount_tax=amount_tax, + total_amount=refund.amount, + amount_tax=round(refund.amount * Decimal(TAX), 2), ) return Response(status=status.HTTP_204_NO_CONTENT)