Merge pull request #452 from pcoder/task/3701/enable_monthly_payments

Task/3701/enable monthly payments
This commit is contained in:
Pcoder 2017-08-24 13:42:42 +02:00 committed by GitHub
commit 6206f25b56
15 changed files with 499 additions and 105 deletions

View file

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-08-03 03:10+0530\n" "POT-Creation-Date: 2017-08-24 11:28+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -276,6 +276,19 @@ msgstr "Konfiguration"
msgid "Total" msgid "Total"
msgstr "" msgstr ""
#, fuzzy
#| msgid "month"
msgid "Month"
msgstr "Monat"
#, python-format
msgid ""
"By clicking \"Place order\" this plan will charge your credit card account "
"with the fee of %(vm_price)sCHF/month"
msgstr ""
"Wenn Du \"bestellen\" auswählst, wird Deine Kreditkarte mit %(vm_price)sCHF "
"pro Monat belastet"
msgid "Place order" msgid "Place order"
msgstr "Bestellen" msgstr "Bestellen"

View file

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.4 on 2017-08-16 19:47
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('datacenterlight', '0006_vmtemplate'),
]
operations = [
migrations.CreateModel(
name='StripePlan',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('stripe_plan_id', models.CharField(max_length=100, null=True)),
],
),
]

View file

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.4 on 2017-08-21 20:24
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('datacenterlight', '0007_stripeplan'),
]
operations = [
migrations.AlterField(
model_name='stripeplan',
name='stripe_plan_id',
field=models.CharField(max_length=256, null=True),
),
]

View file

@ -59,3 +59,15 @@ class VMTemplate(models.Model):
def create(cls, name, opennebula_vm_template_id): def create(cls, name, opennebula_vm_template_id):
vm_template = cls(name=name, opennebula_vm_template_id=opennebula_vm_template_id) vm_template = cls(name=name, opennebula_vm_template_id=opennebula_vm_template_id)
return vm_template return vm_template
class StripePlan(models.Model):
"""
A model to store Data Center Light's created Stripe plans
"""
stripe_plan_id = models.CharField(max_length=256, null=True)
@classmethod
def create(cls, stripe_plan_id):
stripe_plan = cls(stripe_plan_id=stripe_plan_id)
return stripe_plan

View file

@ -44,7 +44,7 @@ def retry_task(task, exception=None):
def create_vm_task(self, vm_template_id, user, specs, template, def create_vm_task(self, vm_template_id, user, specs, template,
stripe_customer_id, billing_address_data, stripe_customer_id, billing_address_data,
billing_address_id, billing_address_id,
charge): charge, cc_details):
vm_id = None vm_id = None
try: try:
final_price = specs.get('price') final_price = specs.get('price')
@ -91,9 +91,9 @@ def create_vm_task(self, vm_template_id, user, specs, template,
billing_address_user_form.is_valid() billing_address_user_form.is_valid()
billing_address_user_form.save() billing_address_user_form.save()
# Associate an order with a stripe payment # Associate an order with a stripe subscription
charge_object = DictDotLookup(charge) charge_object = DictDotLookup(charge)
order.set_stripe_charge(charge_object) order.set_subscription_id(charge_object, cc_details)
# If the Stripe payment succeeds, set order status approved # If the Stripe payment succeeds, set order status approved
order.set_approved() order.set_approved()

View file

@ -67,14 +67,17 @@
<hr> <hr>
<p><b>{% trans "Configuration"%}</b> <span class="pull-right">{{request.session.template.name}}</span></p> <p><b>{% trans "Configuration"%}</b> <span class="pull-right">{{request.session.template.name}}</span></p>
<hr> <hr>
<h4>{% trans "Total"%}<p class="pull-right"><b>{{vm.price}} CHF</b></p></h4> <h4>{% trans "Total"%}<p class="pull-right"><b>{{vm.price}} CHF</b><span class="dcl-price-month"> /{% trans "Month" %}</span></p></h4>
{% endwith %} {% endwith %}
</div> </div>
<br/> <br/>
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
<div class=" content pull-right"> <div class="col-md-8 col-xs-7 pull-left tbl-no-padding">
<a href="{{next_url}}" ><button class="btn btn-info">{% trans "Place order"%}</button></a> <p class="dcl-place-order-text">{% blocktrans with vm_price=request.session.specs.price %}By clicking "Place order" this plan will charge your credit card account with the fee of {{ vm_price }}CHF/month{% endblocktrans %}.</p>
</div>
<div class="col-md-4 col-xs-5 content tbl-no-padding">
<a href="{{next_url}}" ><button class="btn btn-info pull-right">{% trans "Place order"%}</button></a>
</div> </div>
</form> </form>
</div> </div>

View file

@ -50,7 +50,12 @@ class CeleryTaskTestCase(TestCase):
call_command('fetchvmtemplates') call_command('fetchvmtemplates')
def test_create_vm_task(self): def test_create_vm_task(self):
"""Tests the create vm task.""" """Tests the create vm task for monthly subscription
This test is supposed to validate the proper execution
of celery create_vm_task on production, as we have no
other way to do this.
"""
# We create a VM from the first template available to DCL # We create a VM from the first template available to DCL
vm_template = VMTemplate.objects.all().first() vm_template = VMTemplate.objects.all().first()
@ -61,12 +66,16 @@ class CeleryTaskTestCase(TestCase):
'cpu': 1, 'cpu': 1,
'memory': 2, 'memory': 2,
'disk_size': 10, 'disk_size': 10,
'price': 15, 'price': 15
} }
stripe_customer = StripeCustomer.get_or_create( stripe_customer = StripeCustomer.get_or_create(
email=self.customer_email, email=self.customer_email,
token=self.token) token=self.token)
card_details = self.stripe_utils.get_card_details(
stripe_customer.stripe_id,
self.token)
card_details_dict = card_details.get('response_object')
billing_address = BillingAddress( billing_address = BillingAddress(
cardholder_name=self.customer_name, cardholder_name=self.customer_name,
postal_code='1232', postal_code='1232',
@ -83,28 +92,44 @@ class CeleryTaskTestCase(TestCase):
billing_address_id = billing_address.id billing_address_id = billing_address.id
vm_template_id = template_data.get('id', 1) vm_template_id = template_data.get('id', 1)
final_price = specs.get('price')
# Make stripe charge to a customer cpu = specs.get('cpu')
stripe_utils = StripeUtils() memory = specs.get('memory')
charge_response = stripe_utils.make_charge( disk_size = specs.get('disk_size')
amount=final_price, amount_to_be_charged = (cpu * 5) + (memory * 2) + (disk_size * 0.6)
customer=stripe_customer.stripe_id) plan_name = "{cpu} Cores, {memory} GB RAM, {disk_size} GB SSD".format(
cpu=cpu,
memory=memory,
disk_size=disk_size)
# Check if the payment was approved stripe_plan_id = StripeUtils.get_stripe_plan_id(cpu=cpu,
if not charge_response.get( ram=memory,
'response_object'): ssd=disk_size,
msg = charge_response.get('error') version=1,
raise Exception("make_charge failed: {}".format(msg)) app='dcl')
stripe_plan = self.stripe_utils.get_or_create_stripe_plan(
amount=amount_to_be_charged,
name=plan_name,
stripe_plan_id=stripe_plan_id)
subscription_result = self.stripe_utils.subscribe_customer_to_plan(
stripe_customer.stripe_id,
[{"plan": stripe_plan.get(
'response_object').stripe_plan_id}])
stripe_subscription_obj = subscription_result.get('response_object')
# Check if the subscription was approved and is active
if stripe_subscription_obj is None or \
stripe_subscription_obj.status != 'active':
msg = subscription_result.get('error')
raise Exception("Creating subscription failed: {}".format(msg))
charge = charge_response.get('response_object')
async_task = create_vm_task.delay(vm_template_id, self.user, async_task = create_vm_task.delay(vm_template_id, self.user,
specs, specs,
template_data, template_data,
stripe_customer.id, stripe_customer.id,
billing_address_data, billing_address_data,
billing_address_id, billing_address_id,
charge) stripe_subscription_obj,
card_details_dict)
new_vm_id = 0 new_vm_id = 0
res = None res = None
for i in range(0, 10): for i in range(0, 10):

View file

@ -473,25 +473,53 @@ class OrderConfirmationView(DetailView):
billing_address_data = request.session.get('billing_address_data') billing_address_data = request.session.get('billing_address_data')
billing_address_id = request.session.get('billing_address') billing_address_id = request.session.get('billing_address')
vm_template_id = template.get('id', 1) vm_template_id = template.get('id', 1)
final_price = specs.get('price')
# Make stripe charge to a customer # Make stripe charge to a customer
stripe_utils = StripeUtils() stripe_utils = StripeUtils()
charge_response = stripe_utils.make_charge(amount=final_price, card_details = stripe_utils.get_card_details(customer.stripe_id,
customer=customer.stripe_id) request.session.get(
'token'))
# Check if the payment was approved if not card_details.get('response_object'):
if not charge_response.get('response_object'): msg = card_details.get('error')
msg = charge_response.get('error')
messages.add_message(self.request, messages.ERROR, msg, messages.add_message(self.request, messages.ERROR, msg,
extra_tags='make_charge_error') extra_tags='failed_payment')
return HttpResponseRedirect( return HttpResponseRedirect(
reverse('datacenterlight:payment') + '#payment_error') reverse('datacenterlight:payment') + '#payment_error')
card_details_dict = card_details.get('response_object')
cpu = specs.get('cpu')
memory = specs.get('memory')
disk_size = specs.get('disk_size')
amount_to_be_charged = (cpu * 5) + (memory * 2) + (disk_size * 0.6)
plan_name = "{cpu} Cores, {memory} GB RAM, {disk_size} GB SSD".format(
cpu=cpu,
memory=memory,
disk_size=disk_size)
charge = charge_response.get('response_object') stripe_plan_id = StripeUtils.get_stripe_plan_id(cpu=cpu,
ram=memory,
ssd=disk_size,
version=1,
app='dcl')
stripe_plan = stripe_utils.get_or_create_stripe_plan(
amount=amount_to_be_charged,
name=plan_name,
stripe_plan_id=stripe_plan_id)
subscription_result = stripe_utils.subscribe_customer_to_plan(
customer.stripe_id,
[{"plan": stripe_plan.get(
'response_object').stripe_plan_id}])
stripe_subscription_obj = subscription_result.get('response_object')
# Check if the subscription was approved and is active
if stripe_subscription_obj is None or \
stripe_subscription_obj.status != 'active':
msg = subscription_result.get('error')
messages.add_message(self.request, messages.ERROR, msg,
extra_tags='failed_payment')
return HttpResponseRedirect(
reverse('datacenterlight:payment') + '#payment_error')
create_vm_task.delay(vm_template_id, user, specs, template, create_vm_task.delay(vm_template_id, user, specs, template,
stripe_customer_id, billing_address_data, stripe_customer_id, billing_address_data,
billing_address_id, billing_address_id,
charge) stripe_subscription_obj, card_details_dict)
request.session['order_confirmation'] = True request.session['order_confirmation'] = True
return HttpResponseRedirect(reverse('datacenterlight:order_success')) return HttpResponseRedirect(reverse('datacenterlight:order_success'))

View file

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-08-20 21:37+0530\n" "POT-Creation-Date: 2017-08-24 11:12+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -254,6 +254,9 @@ msgstr "Betrag"
msgid "Status" msgid "Status"
msgstr "" msgstr ""
msgid "See Invoice"
msgstr "Rechnung"
msgid "View Detail" msgid "View Detail"
msgstr "Details anzeigen" msgstr "Details anzeigen"
@ -272,6 +275,9 @@ msgstr "Konfiguration"
msgid "including VAT" msgid "including VAT"
msgstr "inkl. Mehrwertsteuer" msgstr "inkl. Mehrwertsteuer"
msgid "Month"
msgstr "Monat"
msgid "Billing Address" msgid "Billing Address"
msgstr "Rechnungsadresse" msgstr "Rechnungsadresse"
@ -292,23 +298,10 @@ msgstr ""
"\"https://stripe.com\" target=\"_blank\">Stripe</a> für die Bezahlung und " "\"https://stripe.com\" target=\"_blank\">Stripe</a> für die Bezahlung und "
"speichern keine Informationen in unserer Datenbank." "speichern keine Informationen in unserer Datenbank."
#, fuzzy
#| msgid ""
#| "\n"
#| " You are not making any "
#| "payment yet. After submitting your card\n"
#| " information, you will be "
#| "taken to the Confirm Order Page.\n"
#| " "
msgid "" msgid ""
"\n" "You are not making any payment yet. After submitting your card information, "
" You are not making any " "you will be taken to the Confirm Order Page."
"payment yet. After submitting your card\n"
" information, you will be "
"taken to the Confirm Order Page.\n"
" "
msgstr "" msgstr ""
"\n"
"Es wird noch keine Bezahlung vorgenommen. Nach der Eingabe Deiner " "Es wird noch keine Bezahlung vorgenommen. Nach der Eingabe Deiner "
"Kreditkateninformationen wirst du auf die Bestellbestätigungsseite " "Kreditkateninformationen wirst du auf die Bestellbestätigungsseite "
"weitergeleitet." "weitergeleitet."
@ -391,7 +384,7 @@ msgstr "Anzeigen"
#, fuzzy #, fuzzy
#| msgid "Public SSH Key" #| msgid "Public SSH Key"
msgid "Public SSH key" msgid "Public SSH Key"
msgstr "Public SSH Key" msgstr "Public SSH Key"
msgid "Download" msgid "Download"
@ -415,12 +408,6 @@ msgstr "Abrechnungen"
msgid "Current Pricing" msgid "Current Pricing"
msgstr "Aktueller Preis" msgstr "Aktueller Preis"
msgid "Month"
msgstr "Monat"
msgid "See Invoice"
msgstr "Rechnung"
msgid "Your VM is" msgid "Your VM is"
msgstr "Deine VM ist" msgstr "Deine VM ist"
@ -507,6 +494,19 @@ msgid ""
"contact Data Center Light Support." "contact Data Center Light Support."
msgstr "" msgstr ""
#~ msgid ""
#~ "\n"
#~ " You are not making any "
#~ "payment yet. After submitting your card\n"
#~ " information, you will be "
#~ "taken to the Confirm Order Page.\n"
#~ " "
#~ msgstr ""
#~ "\n"
#~ "Es wird noch keine Bezahlung vorgenommen. Nach der Eingabe Deiner "
#~ "Kreditkateninformationen wirst du auf die Bestellbestätigungsseite "
#~ "weitergeleitet."
#~ msgid "Approved" #~ msgid "Approved"
#~ msgstr "Akzeptiert" #~ msgstr "Akzeptiert"
@ -670,14 +670,6 @@ msgstr ""
#~ msgid "Place Order" #~ msgid "Place Order"
#~ msgstr "Bestelle" #~ msgstr "Bestelle"
#~ msgid ""
#~ "You are not making any payment yet. After placing your order, you will be "
#~ "taken to the Submit Payment Page."
#~ msgstr ""
#~ "Es wird noch keine Bezahlung vorgenommen. Nach der Eingabe deiner "
#~ "Kreditkateninformationen wirst du auf die Bestellbestätigungsseite "
#~ "weitergeleitet."
#~ msgid "CARD NUMBER" #~ msgid "CARD NUMBER"
#~ msgstr "Kreditkartennummer" #~ msgstr "Kreditkartennummer"

View file

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.4 on 2017-08-17 16:34
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('hosting', '0041_userhostingkey_private_key'),
]
operations = [
migrations.AddField(
model_name='hostingorder',
name='subscription_id',
field=models.CharField(max_length=100, null=True),
),
]

View file

@ -50,6 +50,7 @@ class HostingOrder(AssignPermissionsMixin, models.Model):
cc_brand = models.CharField(max_length=10) cc_brand = models.CharField(max_length=10)
stripe_charge_id = models.CharField(max_length=100, null=True) stripe_charge_id = models.CharField(max_length=100, null=True)
price = models.FloatField() price = models.FloatField()
subscription_id = models.CharField(max_length=100, null=True)
permissions = ('view_hostingorder',) permissions = ('view_hostingorder',)
@ -66,7 +67,8 @@ class HostingOrder(AssignPermissionsMixin, models.Model):
return self.ORDER_APPROVED_STATUS if self.approved else self.ORDER_DECLINED_STATUS return self.ORDER_APPROVED_STATUS if self.approved else self.ORDER_DECLINED_STATUS
@classmethod @classmethod
def create(cls, price=None, vm_id=None, customer=None, billing_address=None): def create(cls, price=None, vm_id=None, customer=None,
billing_address=None):
instance = cls.objects.create( instance = cls.objects.create(
price=price, price=price,
vm_id=vm_id, vm_id=vm_id,
@ -86,6 +88,23 @@ class HostingOrder(AssignPermissionsMixin, models.Model):
self.cc_brand = stripe_charge.source.brand self.cc_brand = stripe_charge.source.brand
self.save() self.save()
def set_subscription_id(self, subscription_object, cc_details):
"""
When creating a Stripe subscription, we have subscription id.
We store this in the subscription_id field.
This method sets the subscription id from subscription_object
and also the last4 and credit card brands used for this order.
:param subscription_object: Stripe's subscription object
:param cc_details: A dict containing card details
{last4, brand}
:return:
"""
self.subscription_id = subscription_object.id
self.last4 = cc_details.get('last4')
self.cc_brand = cc_details.get('brand')
self.save()
def get_cc_data(self): def get_cc_data(self):
return { return {
'last4': self.last4, 'last4': self.last4,
@ -137,5 +156,6 @@ class HostingBill(AssignPermissionsMixin, models.Model):
@classmethod @classmethod
def create(cls, customer=None, billing_address=None): def create(cls, customer=None, billing_address=None):
instance = cls.objects.create(customer=customer, billing_address=billing_address) instance = cls.objects.create(customer=customer,
billing_address=billing_address)
return instance return instance

View file

@ -533,9 +533,21 @@ a.unlink:hover {
padding-left: 5px; padding-left: 5px;
} }
.dcl-place-order-text{
font-size: 13px;
color: #808080;
}
.dcl-order-table-total .tbl-total { .dcl-order-table-total .tbl-total {
text-align: center; text-align: center;
color: #000; color: #000;
padding-left: 44px;
}
.tbl-total .dcl-price-month {
font-size: 16px;
text-transform: capitalize;
color: #000;
} }
.tbl-no-padding { .tbl-no-padding {
@ -782,4 +794,4 @@ a.list-group-item-danger.active:focus {
} }
.panel-danger > .panel-heading .badge { .panel-danger > .panel-heading .badge {
background-color: #eb4d5c; background-color: #eb4d5c;
} }

View file

@ -41,9 +41,9 @@
{%trans "Total" %} <span>{%trans "including VAT" %}</span> {%trans "Total" %} <span>{%trans "including VAT" %}</span>
</div> </div>
<div class="col-xs-6 col-sm-6 col-md-6 col-lg-6 tbl-no-padding"> <div class="col-xs-6 col-sm-6 col-md-6 col-lg-6 tbl-no-padding">
<div class="col-xs-12 col-sm-6 col-md-6 col-lg-6"></div> <div class="col-xs-12 col-sm-4 col-md-4 col-lg-4"></div>
<div class="col-xs-12 col-sm-4 col-md-4 col-lg-4 tbl-total">{{request.session.specs.price}} <div class="col-xs-12 col-sm-6 col-md-6 col-lg-6 tbl-total">{{request.session.specs.price}}
CHF CHF<span class="dcl-price-month">/{% trans "Month" %}</span>
</div> </div>
</div> </div>
</div> </div>
@ -87,10 +87,7 @@
<div class="col-xs-12"> <div class="col-xs-12">
{% if not messages and not form.non_field_errors %} {% if not messages and not form.non_field_errors %}
<p class="card-warning-content card-warning-addtional-margin"> <p class="card-warning-content card-warning-addtional-margin">
{% blocktrans %} {% blocktrans %}You are not making any payment yet. After submitting your card information, you will be taken to the Confirm Order Page.{% endblocktrans %}
You are not making any payment yet. After submitting your card
information, you will be taken to the Confirm Order Page.
{% endblocktrans %}
</p> </p>
{% endif %} {% endif %}
<div id='payment_error'> <div id='payment_error'>
@ -147,10 +144,7 @@
<div class="col-xs-12"> <div class="col-xs-12">
{% if not messages and not form.non_field_errors %} {% if not messages and not form.non_field_errors %}
<p class="card-warning-content"> <p class="card-warning-content">
{% blocktrans %} {% blocktrans %}You are not making any payment yet. After submitting your card information, you will be taken to the Confirm Order Page.{% endblocktrans %}
You are not making any payment yet. After submitting your card
information, you will be taken to the Confirm Order Page.
{% endblocktrans %}
</p> </p>
{% endif %} {% endif %}
<div id='payment_error'> <div id='payment_error'>

View file

@ -1,6 +1,10 @@
import logging
import stripe import stripe
from django.conf import settings from django.conf import settings
from datacenterlight.models import StripePlan
stripe.api_key = settings.STRIPE_API_PRIVATE_KEY stripe.api_key = settings.STRIPE_API_PRIVATE_KEY
logger = logging.getLogger(__name__)
def handleStripeError(f): def handleStripeError(f):
@ -26,7 +30,8 @@ def handleStripeError(f):
response.update({'error': err['message']}) response.update({'error': err['message']})
return response return response
except stripe.error.RateLimitError as e: except stripe.error.RateLimitError as e:
response.update({'error': "Too many requests made to the API too quickly"}) response.update(
{'error': "Too many requests made to the API too quickly"})
return response return response
except stripe.error.InvalidRequestError as e: except stripe.error.InvalidRequestError as e:
response.update({'error': "Invalid parameters"}) response.update({'error': "Invalid parameters"})
@ -55,6 +60,10 @@ class StripeUtils(object):
CURRENCY = 'chf' CURRENCY = 'chf'
INTERVAL = 'month' INTERVAL = 'month'
SUCCEEDED_STATUS = 'succeeded' SUCCEEDED_STATUS = 'succeeded'
STRIPE_PLAN_ALREADY_EXISTS = 'Plan already exists'
STRIPE_NO_SUCH_PLAN = 'No such plan'
PLAN_EXISTS_ERROR_MSG = 'Plan {} exists already.\nCreating a local StripePlan now.'
PLAN_DOES_NOT_EXIST_ERROR_MSG = 'Plan {} does not exist.'
def __init__(self): def __init__(self):
self.stripe = stripe self.stripe = stripe
@ -96,7 +105,8 @@ class StripeUtils(object):
customer = stripe.Customer.retrieve(id) customer = stripe.Customer.retrieve(id)
except stripe.InvalidRequestError: except stripe.InvalidRequestError:
customer = self.create_customer(token, user.email, user.name) customer = self.create_customer(token, user.email, user.name)
user.stripecustomer.stripe_id = customer.get('response_object').get('id') user.stripecustomer.stripe_id = customer.get(
'response_object').get('id')
user.stripecustomer.save() user.stripecustomer.save()
return customer return customer
@ -129,13 +139,92 @@ class StripeUtils(object):
return charge return charge
@handleStripeError @handleStripeError
def create_plan(self, amount, name, id): def get_or_create_stripe_plan(self, amount, name, stripe_plan_id):
self.stripe.Plan.create( """
amount=amount, This function checks if a StripePlan with the given
interval=self.INTERVAL, stripe_plan_id already exists. If it exists then the function
name=name, returns this object otherwise it creates a new StripePlan and
currency=self.CURRENCY, returns the new object.
id=id)
:param amount: The amount in CHF
:param name: The name of the Stripe plan to be created.
:param stripe_plan_id: The id of the Stripe plan to be
created. Use get_stripe_plan_id_string function to
obtain the name of the plan to be created
:return: The StripePlan object if it exists else creates a
Plan object in Stripe and a local StripePlan and
returns it. Returns None in case of Stripe error
"""
_amount = float(amount)
amount = int(_amount * 100) # stripe amount unit, in cents
stripe_plan_db_obj = None
try:
stripe_plan_db_obj = StripePlan.objects.get(
stripe_plan_id=stripe_plan_id)
except StripePlan.DoesNotExist:
try:
self.stripe.Plan.create(
amount=amount,
interval=self.INTERVAL,
name=name,
currency=self.CURRENCY,
id=stripe_plan_id)
stripe_plan_db_obj = StripePlan.objects.create(
stripe_plan_id=stripe_plan_id)
except stripe.error.InvalidRequestError as e:
if self.STRIPE_PLAN_ALREADY_EXISTS in str(e):
logger.debug(
self.PLAN_EXISTS_ERROR_MSG.format(stripe_plan_id))
stripe_plan_db_obj = StripePlan.objects.create(
stripe_plan_id=stripe_plan_id)
return stripe_plan_db_obj
@handleStripeError
def delete_stripe_plan(self, stripe_plan_id):
"""
Deletes the Plan in Stripe and also deletes the local db copy
of the plan if it exists
:param stripe_plan_id: The stripe plan id that needs to be
deleted
:return: True if the plan was deleted successfully from
Stripe, False otherwise.
"""
return_value = False
try:
plan = self.stripe.Plan.retrieve(stripe_plan_id)
plan.delete()
return_value = True
StripePlan.objects.filter(
stripe_plan_id=stripe_plan_id).all().delete()
except stripe.error.InvalidRequestError as e:
if self.STRIPE_NO_SUCH_PLAN in str(e):
logger.debug(
self.PLAN_DOES_NOT_EXIST_ERROR_MSG.format(stripe_plan_id))
return return_value
@handleStripeError
def subscribe_customer_to_plan(self, customer, plans):
"""
Subscribes the given customer to the list of given plans
:param customer: The stripe customer identifier
:param plans: A list of stripe plans.
Ref: https://stripe.com/docs/api/python#create_subscription-items
e.g.
plans = [
{
"plan": "dcl-v1-cpu-2-ram-5gb-ssd-10gb",
},
]
:return: The subscription StripeObject
"""
subscription_result = self.stripe.Subscription.create(
customer=customer,
items=plans,
)
return subscription_result
@handleStripeError @handleStripeError
def make_payment(self, customer, amount, token): def make_payment(self, customer, amount, token):
@ -145,3 +234,29 @@ class StripeUtils(object):
customer=customer customer=customer
) )
return charge return charge
@staticmethod
def get_stripe_plan_id(cpu, ram, ssd, version, app='dcl', hdd=None):
"""
Returns the stripe plan id string of the form
`dcl-v1-cpu-2-ram-5gb-ssd-10gb` based on the input parameters
:param cpu: The number of cores
:param ram: The size of the RAM in GB
:param ssd: The size of ssd storage in GB
:param hdd: The size of hdd storage in GB
:param version: The version of the Stripe plans
:param app: The application to which the stripe plan belongs
to. By default it is 'dcl'
:return: A string of the form `dcl-v1-cpu-2-ram-5gb-ssd-10gb`
"""
dcl_plan_string = 'cpu-{cpu}-ram-{ram}gb-ssd-{ssd}gb'.format(cpu=cpu,
ram=ram,
ssd=ssd)
if hdd is not None:
dcl_plan_string = '{dcl_plan_string}-hdd-{hdd}gb'.format(
dcl_plan_string=dcl_plan_string, hdd=hdd)
stripe_plan_id_string = '{app}-v{version}-{plan}'.format(app=app,
version=version,
plan=dcl_plan_string)
return stripe_plan_id_string

View file

@ -1,10 +1,15 @@
from django.test import TestCase import uuid
from django.test import Client from unittest.mock import patch
from django.http.request import HttpRequest
from model_mommy import mommy
from utils.stripe_utils import StripeUtils
import stripe import stripe
from django.http.request import HttpRequest
from django.test import Client
from django.test import TestCase
from model_mommy import mommy
from datacenterlight.models import StripePlan
from membership.models import StripeCustomer
from utils.stripe_utils import StripeUtils
from django.conf import settings from django.conf import settings
@ -18,8 +23,9 @@ class BaseTestCase(TestCase):
self.dummy_password = 'test_password' self.dummy_password = 'test_password'
# Users # Users
self.customer, self.another_customer = mommy.make('membership.CustomUser', self.customer, self.another_customer = mommy.make(
_quantity=2) 'membership.CustomUser',
_quantity=2)
self.customer.set_password(self.dummy_password) self.customer.set_password(self.dummy_password)
self.customer.save() self.customer.save()
self.another_customer.set_password(self.dummy_password) self.another_customer.set_password(self.dummy_password)
@ -94,17 +100,16 @@ class TestStripeCustomerDescription(TestCase):
""" """
def setUp(self): def setUp(self):
self.dummy_password = 'test_password' self.customer_password = 'test_password'
self.dummy_email = 'test@ungleich.ch' self.customer_email = 'test@ungleich.ch'
self.customer_name = "Monty Python"
self.customer = mommy.make('membership.CustomUser') self.customer = mommy.make('membership.CustomUser')
self.customer.set_password(self.dummy_password) self.customer.set_password(self.customer_password)
self.customer.email = self.dummy_email self.customer.email = self.customer_email
self.customer.save() self.customer.save()
stripe.api_key = settings.STRIPE_API_PRIVATE_KEY self.stripe_utils = StripeUtils()
stripe.api_key = settings.STRIPE_API_PRIVATE_KEY_TEST
def test_creating_stripe_customer(self): self.token = stripe.Token.create(
test_name = "Monty Python"
token = stripe.Token.create(
card={ card={
"number": '4111111111111111', "number": '4111111111111111',
"exp_month": 12, "exp_month": 12,
@ -112,8 +117,121 @@ class TestStripeCustomerDescription(TestCase):
"cvc": '123' "cvc": '123'
}, },
) )
stripe_utils = StripeUtils() self.failed_token = stripe.Token.create(
stripe_data = stripe_utils.create_customer(token.id, self.customer.email, test_name) card={
"number": '4000000000000341',
"exp_month": 12,
"exp_year": 2022,
"cvc": '123'
},
)
def test_creating_stripe_customer(self):
stripe_data = self.stripe_utils.create_customer(self.token.id,
self.customer.email,
self.customer_name)
self.assertEqual(stripe_data.get('error'), None) self.assertEqual(stripe_data.get('error'), None)
customer_data = stripe_data.get('response_object') customer_data = stripe_data.get('response_object')
self.assertEqual(customer_data.description, test_name) self.assertEqual(customer_data.description, self.customer_name)
class StripePlanTestCase(TestStripeCustomerDescription):
"""
A class to test Stripe plans
"""
def test_get_stripe_plan_id_string(self):
plan_id_string = StripeUtils.get_stripe_plan_id(cpu=2, ram=20, ssd=100,
version=1, app='dcl')
self.assertEqual(plan_id_string, 'dcl-v1-cpu-2-ram-20gb-ssd-100gb')
plan_id_string = StripeUtils.get_stripe_plan_id(cpu=2, ram=20, ssd=100,
version=1, app='dcl',
hdd=200)
self.assertEqual(plan_id_string,
'dcl-v1-cpu-2-ram-20gb-ssd-100gb-hdd-200gb')
def test_get_or_create_plan(self):
stripe_plan = self.stripe_utils.get_or_create_stripe_plan(2000,
"test plan 1",
stripe_plan_id='test-plan-1')
self.assertIsNone(stripe_plan.get('error'))
self.assertIsInstance(stripe_plan.get('response_object'), StripePlan)
@patch('utils.stripe_utils.logger')
def test_create_duplicate_plans_error_handling(self, mock_logger):
"""
Test details:
1. Create a test plan in Stripe with a particular id
2. Try to recreate the plan with the same id
3. This creates a Stripe error, the code should be able to handle the error
:param mock_logger:
:return:
"""
unique_id = str(uuid.uuid4().hex)
new_plan_id_str = 'test-plan-{}'.format(unique_id)
stripe_plan = self.stripe_utils.get_or_create_stripe_plan(2000,
"test plan {}".format(
unique_id),
stripe_plan_id=new_plan_id_str)
self.assertIsInstance(stripe_plan.get('response_object'), StripePlan)
self.assertEqual(stripe_plan.get('response_object').stripe_plan_id,
new_plan_id_str)
# Test creating the same plan again and expect the PLAN_EXISTS_ERROR_MSG
# We first delete the local Stripe Plan, so that the code tries to create a new plan in Stripe
StripePlan.objects.filter(
stripe_plan_id=new_plan_id_str).all().delete()
stripe_plan_1 = self.stripe_utils.get_or_create_stripe_plan(2000,
"test plan {}".format(
unique_id),
stripe_plan_id=new_plan_id_str)
mock_logger.debug.assert_called_with(
self.stripe_utils.PLAN_EXISTS_ERROR_MSG.format(new_plan_id_str))
self.assertIsInstance(stripe_plan_1.get('response_object'), StripePlan)
self.assertEqual(stripe_plan_1.get('response_object').stripe_plan_id,
new_plan_id_str)
# Delete the test stripe plan that we just created
delete_result = self.stripe_utils.delete_stripe_plan(new_plan_id_str)
self.assertIsInstance(delete_result, dict)
self.assertEqual(delete_result.get('response_object'), True)
@patch('utils.stripe_utils.logger')
def test_delete_unexisting_plan_should_fail(self, mock_logger):
plan_id = 'crazy-plan-id-that-does-not-exist'
result = self.stripe_utils.delete_stripe_plan(plan_id)
self.assertIsInstance(result, dict)
self.assertEqual(result.get('response_object'), False)
mock_logger.debug.assert_called_with(
self.stripe_utils.PLAN_DOES_NOT_EXIST_ERROR_MSG.format(plan_id))
def test_subscribe_customer_to_plan(self):
stripe_plan = self.stripe_utils.get_or_create_stripe_plan(2000,
"test plan 1",
stripe_plan_id='test-plan-1')
stripe_customer = StripeCustomer.get_or_create(
email=self.customer_email,
token=self.token)
result = self.stripe_utils.subscribe_customer_to_plan(
stripe_customer.stripe_id,
[{"plan": stripe_plan.get(
'response_object').stripe_plan_id}])
self.assertIsInstance(result.get('response_object'),
stripe.Subscription)
self.assertIsNone(result.get('error'))
self.assertEqual(result.get('response_object').get('status'), 'active')
def test_subscribe_customer_to_plan_failed_payment(self):
stripe_plan = self.stripe_utils.get_or_create_stripe_plan(2000,
"test plan 1",
stripe_plan_id='test-plan-1')
stripe_customer = StripeCustomer.get_or_create(
email=self.customer_email,
token=self.failed_token)
result = self.stripe_utils.subscribe_customer_to_plan(
stripe_customer.stripe_id,
[{"plan": stripe_plan.get(
'response_object').stripe_plan_id}])
self.assertIsNone(result.get('response_object'), None)
self.assertIsNotNone(result.get('error'))