diff --git a/Changelog b/Changelog index 1ad8f324..63c4fd07 100644 --- a/Changelog +++ b/Changelog @@ -8,6 +8,7 @@ Next: * #3601: [dcl, hosting] Change minimum required RAM from 2GB to 1GB * #3973: [dcl] Update datacenterlight and glasfaser contact address to Linthal and company name to "ungleich glarus ag" * #3993: [dg] Fix new user membership payment by setting cardholder_name field for UserBillingAddressForm + * #3799: [dg] Make digital glarus billing work as monthly subscription 1.2.13: 2017-12-09 * [cms] Introduce UngleichHeaderBackgroundImageAndTextSliderPlugin that allows to have scrolling images and texts * [cms] Remove

tag for ungleich cms customer item template diff --git a/digitalglarus/migrations/0025_membershiporder_stripe_subscription_id.py b/digitalglarus/migrations/0025_membershiporder_stripe_subscription_id.py new file mode 100644 index 00000000..127d5ff8 --- /dev/null +++ b/digitalglarus/migrations/0025_membershiporder_stripe_subscription_id.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.4 on 2017-12-23 22:56 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('digitalglarus', '0024_bookingcancellation'), + ] + + operations = [ + migrations.AddField( + model_name='membershiporder', + name='stripe_subscription_id', + field=models.CharField(max_length=100, null=True), + ), + ] diff --git a/digitalglarus/models.py b/digitalglarus/models.py index 16d6b639..fc4289aa 100644 --- a/digitalglarus/models.py +++ b/digitalglarus/models.py @@ -1,6 +1,6 @@ - - import calendar +import time + from datetime import datetime, date, timedelta from dateutil.relativedelta import relativedelta from django.db import models @@ -59,6 +59,17 @@ class MembershipType(models.Model): return "{} - {}".format(datetime.strftime(start_date, "%b, %d %Y"), datetime.strftime(end_date, "%b, %d %Y")) + @cached_property + def next_month_in_sec_since_epoch(self): + """ + First day of the next month expressed in seconds since the epoch time + :return: Time in seconds + """ + start_date, end_date = self.first_month_range + first_day_next_month = end_date + timedelta(days=1) + epoch_time = int(time.mktime(first_day_next_month.timetuple())) + return epoch_time + class Membership(models.Model): type = models.ForeignKey(MembershipType) @@ -71,12 +82,12 @@ class Membership(models.Model): @classmethod def get_current_membership(cls, user): - - has_order_current_month = Q(membershiporder__customer__user=user, - membershiporder__created_at__month=datetime.today().month) + has_order_current_month = Q( + membershiporder__customer__user=user, + membershiporder__created_at__month=datetime.today().month + ) # import pdb;pdb.set_trace() - return cls.objects.\ - filter(has_order_current_month).last() + return cls.objects.filter(has_order_current_month).last() # def get_current_active_membership(cls, user): # membership = cls.get_current_membership(user) @@ -84,8 +95,7 @@ class Membership(models.Model): @classmethod def get_by_user(cls, user): - return cls.objects.\ - filter(membershiporder__customer__user=user).last() + return cls.objects.filter(membershiporder__customer__user=user).last() @classmethod def create(cls, data): @@ -96,18 +106,23 @@ class Membership(models.Model): def activate_or_crete(cls, data, user): membership = cls.get_by_user(user) membership_id = membership.id if membership else None - obj, created = cls.objects.update_or_create(id=membership_id, defaults=data) + obj, created = cls.objects.update_or_create( + id=membership_id, defaults=data + ) return obj @classmethod def is_digitalglarus_active_member(cls, user): # past_month = (datetime.today() - relativedelta(months=1)).month - has_order_current_month = Q(membershiporder__customer__user=user, - membershiporder__created_at__month=datetime.today().month) + has_order_current_month = Q( + membershiporder__customer__user=user, + membershiporder__created_at__month=datetime.today().month + ) # has_order_past_month = Q(membershiporder__customer__user=user, # membershiporder__created_at__month=past_month) active_membership = Q(active=True) - # return cls.objects.filter(has_order_past_month | has_order_current_month).\ + # return cls.objects.filter( + # has_order_past_month | has_order_current_month).\ return cls.objects.filter(has_order_current_month).\ filter(active_membership).exists() @@ -129,6 +144,7 @@ class MembershipOrder(Ordereable, models.Model): membership = models.ForeignKey(Membership) start_date = models.DateField() end_date = models.DateField() + stripe_subscription_id = models.CharField(max_length=100, null=True) @classmethod def current_membership_dates(cls, user): @@ -172,10 +188,12 @@ class MembershipOrder(Ordereable, models.Model): @classmethod def create(cls, data): stripe_charge = data.pop('stripe_charge', None) + stripe_subscription_id = data.pop('stripe_subscription_id', None) instance = cls.objects.create(**data) instance.stripe_charge_id = stripe_charge.id instance.last4 = stripe_charge.source.last4 instance.cc_brand = stripe_charge.source.brand + instance.stripe_subscription_id = stripe_subscription_id instance.save() return instance diff --git a/digitalglarus/templates/digitalglarus/membership_orders_list.html b/digitalglarus/templates/digitalglarus/membership_orders_list.html index ceeea6f1..dd49837d 100644 --- a/digitalglarus/templates/digitalglarus/membership_orders_list.html +++ b/digitalglarus/templates/digitalglarus/membership_orders_list.html @@ -95,12 +95,9 @@ Deactivate {% elif not current_membership.active %} -

- {% csrf_token %}
- + Reactivate
-
{% endif %} {% else %}
diff --git a/digitalglarus/test_views.py b/digitalglarus/test_views.py index cdd23bad..b7fc6c3a 100644 --- a/digitalglarus/test_views.py +++ b/digitalglarus/test_views.py @@ -1,5 +1,5 @@ from model_mommy import mommy -from unittest import mock +from unittest import mock, skipIf from django.test import TestCase from django.conf import settings @@ -126,6 +126,11 @@ class MembershipPaymentViewTest(BaseTestCase): self.assertEqual(response.context['membership_type'], self.membership_type) + @skipIf( + settings.STRIPE_API_PRIVATE_KEY_TEST is None or + settings.STRIPE_API_PRIVATE_KEY_TEST is "", + """Stripe details unavailable, so skipping CeleryTaskTestCase""" + ) @mock.patch('utils.stripe_utils.StripeUtils.create_customer') def test_post(self, stripe_mocked_call): diff --git a/digitalglarus/views.py b/digitalglarus/views.py index 96983d9b..32d8e1f5 100644 --- a/digitalglarus/views.py +++ b/digitalglarus/views.py @@ -1,3 +1,5 @@ +import logging + from django.conf import settings from django.shortcuts import render from django.http import HttpResponseRedirect @@ -9,25 +11,34 @@ from django.utils.translation import get_language from djangocms_blog.models import Post from django.contrib import messages from django.views.generic import DetailView, ListView -from .models import Supporter -from .mixins import ChangeMembershipStatusMixin from utils.forms import ContactUsForm from utils.mailer import BaseEmail from django.views.generic.edit import FormView from membership.models import StripeCustomer -from utils.views import LoginViewMixin, SignupViewMixin, \ - PasswordResetViewMixin, PasswordResetConfirmViewMixin -from utils.forms import PasswordResetRequestForm, UserBillingAddressForm, EditCreditCardForm +from utils.views import ( + LoginViewMixin, SignupViewMixin, PasswordResetViewMixin, + PasswordResetConfirmViewMixin +) +from utils.forms import ( + PasswordResetRequestForm, UserBillingAddressForm, EditCreditCardForm +) from utils.stripe_utils import StripeUtils from utils.models import UserBillingAddress +from utils.tasks import send_plain_email_task -from .forms import LoginForm, SignupForm, MembershipBillingForm, BookingDateForm,\ +from .forms import ( + LoginForm, SignupForm, MembershipBillingForm, BookingDateForm, BookingBillingForm, CancelBookingForm +) +from .models import ( + MembershipType, Membership, MembershipOrder, Booking, BookingPrice, + BookingOrder, BookingCancellation, Supporter +) +from .mixins import ( + MembershipRequiredMixin, IsNotMemberMixin, ChangeMembershipStatusMixin +) -from .models import MembershipType, Membership, MembershipOrder, Booking, BookingPrice,\ - BookingOrder, BookingCancellation - -from .mixins import MembershipRequiredMixin, IsNotMemberMixin +logger = logging.getLogger(__name__) class IndexView(TemplateView): @@ -271,7 +282,6 @@ class BookingPaymentView(LoginRequiredMixin, MembershipRequiredMixin, FormView): booking_data = { 'start_date': start_date, 'end_date': end_date, - 'start_date': start_date, 'free_days': free_days, 'price': normal_price, 'final_price': final_price, @@ -355,16 +365,21 @@ class MembershipPaymentView(LoginRequiredMixin, IsNotMemberMixin, FormView): membership_type = data.get('membership_type') # Get or create stripe customer - customer = StripeCustomer.get_or_create(email=self.request.user.email, - token=token) + customer = StripeCustomer.get_or_create( + email=self.request.user.email, token=token + ) if not customer: form.add_error("__all__", "Invalid credit card") - return self.render_to_response(self.get_context_data(form=form)) + return self.render_to_response( + self.get_context_data(form=form) + ) # Make stripe charge to a customer stripe_utils = StripeUtils() - charge_response = stripe_utils.make_charge(amount=membership_type.first_month_price, - customer=customer.stripe_id) + charge_response = stripe_utils.make_charge( + amount=membership_type.first_month_price, + customer=customer.stripe_id + ) charge = charge_response.get('response_object') # Check if the payment was approved @@ -373,6 +388,58 @@ class MembershipPaymentView(LoginRequiredMixin, IsNotMemberMixin, FormView): 'paymentError': charge_response.get('error'), 'form': form }) + email_to_admin_data = { + 'subject': "Could not create charge for Digital Glarus " + "user: {user}".format( + user=self.request.user.email + ), + 'from_email': 'info@digitalglarus.ch', + 'to': ['info@ungleich.ch'], + 'body': "\n".join( + ["%s=%s" % (k, v) for (k, v) in + charge_response.items()]), + } + send_plain_email_task.delay(email_to_admin_data) + return render(request, self.template_name, context) + + # Subscribe the customer to dg plan from the next month onwards + stripe_plan = stripe_utils.get_or_create_stripe_plan( + amount=membership_type.price, + name='Digital Glarus {sub_type_name} Subscription'.format( + sub_type_name=membership_type.name + ), + stripe_plan_id='dg-{sub_type_name}'.format( + sub_type_name=membership_type.name + ) + ) + subscription_result = stripe_utils.subscribe_customer_to_plan( + customer.stripe_id, + [{"plan": stripe_plan.get('response_object').stripe_plan_id}], + trial_end=membership_type.next_month_in_sec_since_epoch + ) + stripe_subscription_obj = subscription_result.get( + 'response_object' + ) + # Check if call to create subscription was ok + if (stripe_subscription_obj is None or + (stripe_subscription_obj.status != 'active' and + stripe_subscription_obj.status != 'trialing')): + context.update({ + 'paymentError': subscription_result.get('error'), + 'form': form + }) + email_to_admin_data = { + 'subject': "Could not create Stripe subscription for " + "Digital Glarus user: {user}".format( + user=self.request.user.email + ), + 'from_email': 'info@digitalglarus.ch', + 'to': ['info@ungleich.ch'], + 'body': "\n".join( + ["%s=%s" % (k, v) for (k, v) in + subscription_result.items()]), + } + send_plain_email_task.delay(email_to_admin_data) return render(request, self.template_name, context) charge = charge_response.get('response_object') @@ -412,6 +479,7 @@ class MembershipPaymentView(LoginRequiredMixin, IsNotMemberMixin, FormView): 'customer': customer, 'billing_address': billing_address, 'stripe_charge': charge, + 'stripe_subscription_id': stripe_subscription_obj.id, 'amount': membership_type.first_month_price, 'start_date': membership_start_date, 'end_date': membership_end_date @@ -481,8 +549,43 @@ class MembershipDeactivateView(LoginRequiredMixin, UpdateView): def post(self, *args, **kwargs): membership = self.get_object() membership.deactivate() - - messages.add_message(self.request, messages.SUCCESS, self.success_message) + messages.add_message( + self.request, messages.SUCCESS, self.success_message + ) + # cancel Stripe subscription + stripe_utils = StripeUtils() + membership_order = MembershipOrder.objects.filter( + customer__user=self.request.user + ).last() + if membership_order: + if membership_order.stripe_subscription_id: + result = stripe_utils.unsubscribe_customer( + subscription_id=membership_order.stripe_subscription_id + ) + stripe_subscription_obj = result.get('response_object') + # Check if the subscription was canceled + if (stripe_subscription_obj is None or + stripe_subscription_obj.status != 'canceled'): + error_msg = result.get('error') + logger.error( + "Could not cancel Digital Glarus subscription. " + "Reason: {reason}".format( + reason=error_msg + ) + ) + else: + logger.error( + "User {user} may have Stripe subscriptions created " + "manually. Please check.".format( + user=self.request.user.name + ) + ) + else: + logger.error( + "MembershipOrder for {user} not found".format( + user=self.request.user.name + ) + ) return HttpResponseRedirect(self.success_url) diff --git a/ungleich_page/templates/ungleich_page/ungleich/_header_with_background_video_slider_item.html b/ungleich_page/templates/ungleich_page/ungleich/_header_with_background_video_slider_item.html index a576684f..78bb7ad8 100644 --- a/ungleich_page/templates/ungleich_page/ungleich/_header_with_background_video_slider_item.html +++ b/ungleich_page/templates/ungleich_page/ungleich/_header_with_background_video_slider_item.html @@ -1,3 +1,4 @@ +{% load i18n %} {% if instance.image %}
{% endif %} diff --git a/utils/stripe_utils.py b/utils/stripe_utils.py index 58840be0..79bca243 100644 --- a/utils/stripe_utils.py +++ b/utils/stripe_utils.py @@ -210,12 +210,14 @@ class StripeUtils(object): return return_value @handleStripeError - def subscribe_customer_to_plan(self, customer, plans): + def subscribe_customer_to_plan(self, customer, plans, trial_end=None): """ Subscribes the given customer to the list of given plans :param customer: The stripe customer identifier :param plans: A list of stripe plans. + :param trial_end: An integer representing when the Stripe subscription + is supposed to end Ref: https://stripe.com/docs/api/python#create_subscription-items e.g. plans = [ @@ -227,8 +229,7 @@ class StripeUtils(object): """ subscription_result = self.stripe.Subscription.create( - customer=customer, - items=plans, + customer=customer, items=plans, trial_end=trial_end ) return subscription_result