Replace legacy Stripe Charge API by Payment{setup, intent}
This commit is contained in:
parent
cf17373b3f
commit
929211162d
10 changed files with 250 additions and 50 deletions
|
@ -61,7 +61,6 @@ router.register(r'payment-method', payviews.PaymentMethodViewSet, basename='paym
|
||||||
router.register(r'bill', payviews.BillViewSet, basename='bill')
|
router.register(r'bill', payviews.BillViewSet, basename='bill')
|
||||||
router.register(r'order', payviews.OrderViewSet, basename='order')
|
router.register(r'order', payviews.OrderViewSet, basename='order')
|
||||||
router.register(r'payment', payviews.PaymentViewSet, basename='payment')
|
router.register(r'payment', payviews.PaymentViewSet, basename='payment')
|
||||||
router.register(r'payment-method', payviews.PaymentMethodViewSet, basename='payment-methods')
|
|
||||||
|
|
||||||
# VMs
|
# VMs
|
||||||
router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vm')
|
router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vm')
|
||||||
|
|
17
uncloud/uncloud_pay/migrations/0017_auto_20200304_1723.py
Normal file
17
uncloud/uncloud_pay/migrations/0017_auto_20200304_1723.py
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
# Generated by Django 3.0.4 on 2020-03-04 17:23
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('uncloud_pay', '0016_auto_20200303_1552'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='paymentmethod',
|
||||||
|
unique_together=set(),
|
||||||
|
),
|
||||||
|
]
|
13
uncloud/uncloud_pay/migrations/0018_auto_20200305_0819.py
Normal file
13
uncloud/uncloud_pay/migrations/0018_auto_20200305_0819.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
# Generated by Django 3.0.4 on 2020-03-05 08:19
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('uncloud_pay', '0017_auto_20200304_1723'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
]
|
23
uncloud/uncloud_pay/migrations/0019_auto_20200305_0851.py
Normal file
23
uncloud/uncloud_pay/migrations/0019_auto_20200305_0851.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 3.0.4 on 2020-03-05 08:51
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('uncloud_pay', '0018_auto_20200305_0819'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='paymentmethod',
|
||||||
|
name='stripe_setup_intent_id',
|
||||||
|
field=models.CharField(blank=True, max_length=32, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='paymentmethod',
|
||||||
|
name='stripe_card_id',
|
||||||
|
field=models.CharField(blank=True, max_length=32, null=True),
|
||||||
|
),
|
||||||
|
]
|
18
uncloud/uncloud_pay/migrations/0020_auto_20200305_0911.py
Normal file
18
uncloud/uncloud_pay/migrations/0020_auto_20200305_0911.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 3.0.4 on 2020-03-05 09:11
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('uncloud_pay', '0019_auto_20200305_0851'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='paymentmethod',
|
||||||
|
old_name='stripe_card_id',
|
||||||
|
new_name='stripe_payment_method_id',
|
||||||
|
),
|
||||||
|
]
|
|
@ -119,27 +119,31 @@ class PaymentMethod(models.Model):
|
||||||
primary = models.BooleanField(default=True)
|
primary = models.BooleanField(default=True)
|
||||||
|
|
||||||
# Only used for "Stripe" source
|
# Only used for "Stripe" source
|
||||||
stripe_card_id = models.CharField(max_length=32, blank=True, null=True)
|
stripe_payment_method_id = models.CharField(max_length=32, blank=True, null=True)
|
||||||
|
stripe_setup_intent_id = models.CharField(max_length=32, blank=True, null=True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def stripe_card_last4(self):
|
def stripe_card_last4(self):
|
||||||
if self.source == 'stripe':
|
if self.source == 'stripe' and self.active:
|
||||||
card_request = uncloud_pay.stripe.get_card(
|
payment_method = uncloud_pay.stripe.get_payment_method(
|
||||||
StripeCustomer.objects.get(owner=self.owner).stripe_id,
|
self.stripe_payment_method_id)
|
||||||
self.stripe_card_id)
|
return payment_method.card.last4
|
||||||
if card_request['error'] == None:
|
|
||||||
return card_request['response_object']['last4']
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active(self):
|
||||||
|
if self.source == 'stripe' and self.stripe_payment_method_id != None:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
def charge(self, amount):
|
def charge(self, amount):
|
||||||
if amount > 0: # Make sure we don't charge negative amount by errors...
|
if amount > 0: # Make sure we don't charge negative amount by errors...
|
||||||
if self.source == 'stripe':
|
if self.source == 'stripe':
|
||||||
stripe_customer = StripeCustomer.objects.get(owner=self.owner).stripe_id
|
stripe_customer = StripeCustomer.objects.get(owner=self.owner).stripe_id
|
||||||
charge_request = uncloud_pay.stripe.charge_customer(amount, stripe_customer, self.stripe_card_id)
|
charge_request = uncloud_pay.stripe.charge_customer(
|
||||||
|
amount, stripe_customer, self.stripe_payment_method_id)
|
||||||
if charge_request['error'] == None:
|
if charge_request['error'] == None:
|
||||||
payment = Payment(owner=self.owner, source=self.source, amount=amount)
|
payment = Payment(owner=self.owner, source=self.source, amount=amount)
|
||||||
payment.save() # TODO: Check return status
|
payment.save() # TODO: Check return status
|
||||||
|
@ -163,7 +167,8 @@ class PaymentMethod(models.Model):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = [['owner', 'primary']]
|
#API_keyunique_together = [['owner', 'primary']]
|
||||||
|
pass
|
||||||
|
|
||||||
###
|
###
|
||||||
# Bills & Payments.
|
# Bills & Payments.
|
||||||
|
|
|
@ -29,7 +29,7 @@ class PaymentMethodSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = PaymentMethod
|
model = PaymentMethod
|
||||||
fields = ['uuid', 'source', 'description', 'primary', 'stripe_card_last4']
|
fields = ['uuid', 'source', 'description', 'primary', 'stripe_card_last4', 'active']
|
||||||
|
|
||||||
class ChargePaymentMethodSerializer(serializers.Serializer):
|
class ChargePaymentMethodSerializer(serializers.Serializer):
|
||||||
amount = serializers.DecimalField(max_digits=10, decimal_places=2)
|
amount = serializers.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
@ -41,11 +41,10 @@ class CreditCardSerializer(serializers.Serializer):
|
||||||
cvc = serializers.IntegerField()
|
cvc = serializers.IntegerField()
|
||||||
|
|
||||||
class CreatePaymentMethodSerializer(serializers.ModelSerializer):
|
class CreatePaymentMethodSerializer(serializers.ModelSerializer):
|
||||||
credit_card = CreditCardSerializer()
|
please_visit = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = PaymentMethod
|
model = PaymentMethod
|
||||||
fields = ['source', 'description', 'primary', 'credit_card']
|
fields = ['source', 'description', 'primary', 'please_visit']
|
||||||
|
|
||||||
|
|
||||||
###
|
###
|
||||||
|
|
|
@ -10,6 +10,10 @@ import uncloud.secrets
|
||||||
# Static stripe configuration used below.
|
# Static stripe configuration used below.
|
||||||
CURRENCY = 'chf'
|
CURRENCY = 'chf'
|
||||||
|
|
||||||
|
# README: We use the Payment Intent API as described on
|
||||||
|
# https://stripe.com/docs/payments/save-and-reuse
|
||||||
|
|
||||||
|
# For internal use only.
|
||||||
stripe.api_key = uncloud.secrets.STRIPE_KEY
|
stripe.api_key = uncloud.secrets.STRIPE_KEY
|
||||||
|
|
||||||
# Helper (decorator) used to catch errors raised by stripe logic.
|
# Helper (decorator) used to catch errors raised by stripe logic.
|
||||||
|
@ -82,6 +86,9 @@ class CreditCard():
|
||||||
|
|
||||||
# Actual Stripe logic.
|
# Actual Stripe logic.
|
||||||
|
|
||||||
|
def public_api_key():
|
||||||
|
return uncloud.settings.STRIPE_PUBLIC_KEY
|
||||||
|
|
||||||
def get_customer_id_for(user):
|
def get_customer_id_for(user):
|
||||||
try:
|
try:
|
||||||
# .get() raise if there is no matching entry.
|
# .get() raise if there is no matching entry.
|
||||||
|
@ -99,15 +106,17 @@ def get_customer_id_for(user):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@handle_stripe_error
|
@handle_stripe_error
|
||||||
def create_card(customer_id, credit_card):
|
def create_setup_intent(customer_id):
|
||||||
return stripe.Customer.create_source(
|
return stripe.SetupIntent.create(customer=customer_id)
|
||||||
customer_id,
|
|
||||||
card={
|
@handle_stripe_error
|
||||||
'number': credit_card.number,
|
def get_setup_intent(setup_intent_id):
|
||||||
'exp_month': credit_card.exp_month,
|
return stripe.SetupIntent.retrieve(setup_intent_id)
|
||||||
'exp_year': credit_card.exp_year,
|
|
||||||
'cvc': credit_card.cvc
|
def get_payment_method(payment_method_id):
|
||||||
})
|
return stripe.PaymentMethod.retrieve(payment_method_id)
|
||||||
|
|
||||||
|
## Legacy
|
||||||
|
|
||||||
@handle_stripe_error
|
@handle_stripe_error
|
||||||
def get_card(customer_id, card_id):
|
def get_card(customer_id, card_id):
|
||||||
|
@ -116,13 +125,16 @@ def get_card(customer_id, card_id):
|
||||||
@handle_stripe_error
|
@handle_stripe_error
|
||||||
def charge_customer(amount, customer_id, card_id):
|
def charge_customer(amount, customer_id, card_id):
|
||||||
# Amount is in CHF but stripes requires smallest possible unit.
|
# Amount is in CHF but stripes requires smallest possible unit.
|
||||||
# See https://stripe.com/docs/api/charges/create
|
# https://stripe.com/docs/api/payment_intents/create#create_payment_intent-amount
|
||||||
adjusted_amount = int(amount * 100)
|
adjusted_amount = int(amount * 100)
|
||||||
return stripe.Charge.create(
|
return stripe.PaymentIntent.create(
|
||||||
amount=adjusted_amount,
|
amount=adjusted_amount,
|
||||||
currency=CURRENCY,
|
currency=CURRENCY,
|
||||||
customer=customer_id,
|
customer=customer_id,
|
||||||
source=card_id)
|
payment_method=card_id,
|
||||||
|
off_session=True,
|
||||||
|
confirm=True,
|
||||||
|
)
|
||||||
|
|
||||||
@handle_stripe_error
|
@handle_stripe_error
|
||||||
def create_customer(name, email):
|
def create_customer(name, email):
|
||||||
|
|
76
uncloud/uncloud_pay/templates/stripe-payment.html.j2
Normal file
76
uncloud/uncloud_pay/templates/stripe-payment.html.j2
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Stripe Card Registration</title>
|
||||||
|
|
||||||
|
<!-- https://stripe.com/docs/js/appendix/viewport_meta_requirements -->
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
|
||||||
|
<script src="https://js.stripe.com/v3/"></script>
|
||||||
|
<style>
|
||||||
|
#content {
|
||||||
|
width: 400px;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#callback-form {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="content">
|
||||||
|
<h1>Registering Stripe Credit Card</h1>
|
||||||
|
|
||||||
|
<!-- Stripe form and messages -->
|
||||||
|
<span id="message"></span>
|
||||||
|
<form id="setup-form">
|
||||||
|
<div id="card-element"></div>
|
||||||
|
<button type='button' id="card-button">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Dirty hack used for callback to API -->
|
||||||
|
<form id="callback-form" action="{{ callback }}" method="post"></form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Enable Stripe from UI elements -->
|
||||||
|
<script>
|
||||||
|
var stripe = Stripe('{{ stripe_pk }}');
|
||||||
|
|
||||||
|
var elements = stripe.elements();
|
||||||
|
var cardElement = elements.create('card');
|
||||||
|
cardElement.mount('#card-element');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Handle card submission -->
|
||||||
|
<script>
|
||||||
|
var cardButton = document.getElementById('card-button');
|
||||||
|
var messageContainer = document.getElementById('message');
|
||||||
|
var clientSecret = '{{ client_secret }}';
|
||||||
|
|
||||||
|
cardButton.addEventListener('click', function(ev) {
|
||||||
|
|
||||||
|
stripe.confirmCardSetup(
|
||||||
|
clientSecret,
|
||||||
|
{
|
||||||
|
payment_method: {
|
||||||
|
card: cardElement,
|
||||||
|
billing_details: {
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
).then(function(result) {
|
||||||
|
if (result.error) {
|
||||||
|
var message = document.createTextNode('Error:' + result.error.message);
|
||||||
|
messageContainer.appendChild(message);
|
||||||
|
} else {
|
||||||
|
// Return to API on success.
|
||||||
|
document.getElementById("callback-form").submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -1,9 +1,12 @@
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from rest_framework import viewsets, permissions, status
|
from rest_framework import viewsets, permissions, status, views
|
||||||
|
from rest_framework.renderers import TemplateHTMLRenderer
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.reverse import reverse
|
||||||
|
from rest_framework.decorators import renderer_classes
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
@ -69,7 +72,6 @@ class PaymentMethodViewSet(viewsets.ModelViewSet):
|
||||||
else:
|
else:
|
||||||
return PaymentMethodSerializer
|
return PaymentMethodSerializer
|
||||||
|
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return PaymentMethod.objects.filter(owner=self.request.user)
|
return PaymentMethod.objects.filter(owner=self.request.user)
|
||||||
|
|
||||||
|
@ -78,7 +80,9 @@ class PaymentMethodViewSet(viewsets.ModelViewSet):
|
||||||
def create(self, request):
|
def create(self, request):
|
||||||
serializer = self.get_serializer(data=request.data)
|
serializer = self.get_serializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
payment_method = PaymentMethod.objects.create(owner=request.user, **serializer.validated_data)
|
||||||
|
|
||||||
|
if serializer.validated_data['source'] == "stripe":
|
||||||
# Retrieve Stripe customer ID for user.
|
# Retrieve Stripe customer ID for user.
|
||||||
customer_id = uncloud_stripe.get_customer_id_for(request.user)
|
customer_id = uncloud_stripe.get_customer_id_for(request.user)
|
||||||
if customer_id == None:
|
if customer_id == None:
|
||||||
|
@ -86,21 +90,22 @@ class PaymentMethodViewSet(viewsets.ModelViewSet):
|
||||||
{'error': 'Could not resolve customer stripe ID.'},
|
{'error': 'Could not resolve customer stripe ID.'},
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
# Register card under stripe customer.
|
# TODO: handle error
|
||||||
credit_card = uncloud_stripe.CreditCard(**serializer.validated_data.pop('credit_card'))
|
setup_intent = uncloud_stripe.create_setup_intent(customer_id)
|
||||||
card_request = uncloud_stripe.create_card(customer_id, credit_card)
|
payment_method = PaymentMethod.objects.create(
|
||||||
if card_request['error']:
|
owner=request.user,
|
||||||
return Response({'stripe_error': card_request['error']}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
stripe_setup_intent_id=setup_intent['response_object']['id'],
|
||||||
card_id = card_request['response_object']['id']
|
**serializer.validated_data)
|
||||||
|
|
||||||
# Save payment method locally.
|
# TODO: find a way to use reverse properly:
|
||||||
serializer.validated_data['stripe_card_id'] = card_request['response_object']['id']
|
# https://www.django-rest-framework.org/api-guide/reverse/
|
||||||
payment_method = PaymentMethod.objects.create(owner=request.user, **serializer.validated_data)
|
query= "payment-method/{}/register-stripe-cc".format(
|
||||||
|
payment_method.uuid
|
||||||
|
)
|
||||||
|
stripe_registration_url = reverse('api-root', request=request) + query
|
||||||
|
return Response({'please_visit': stripe_registration_url})
|
||||||
|
|
||||||
# We do not want to return the credit card details sent with the POST
|
return Response(serializer.data)
|
||||||
# request.
|
|
||||||
output_serializer = PaymentMethodSerializer(payment_method)
|
|
||||||
return Response(output_serializer.data)
|
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
@action(detail=True, methods=['post'])
|
||||||
def charge(self, request, pk=None):
|
def charge(self, request, pk=None):
|
||||||
|
@ -115,6 +120,39 @@ class PaymentMethodViewSet(viewsets.ModelViewSet):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['get'], url_path='register-stripe-cc', renderer_classes=[TemplateHTMLRenderer])
|
||||||
|
def register_stripe_cc(self, request, pk=None):
|
||||||
|
payment_method = self.get_object()
|
||||||
|
setup_intent = uncloud_stripe.get_setup_intent(
|
||||||
|
payment_method.stripe_setup_intent_id)
|
||||||
|
|
||||||
|
# Render stripe card registration form.
|
||||||
|
template_args = {
|
||||||
|
'client_secret': setup_intent["response_object"]["client_secret"],
|
||||||
|
'stripe_pk': uncloud_stripe.public_api_key
|
||||||
|
}
|
||||||
|
return Response(template_args, template_name='stripe-payment.html.j2')
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'], url_path='register-stripe-cc')
|
||||||
|
def register_stripe_cc(self, request, pk=None):
|
||||||
|
payment_method = self.get_object()
|
||||||
|
setup_intent = uncloud_stripe.get_setup_intent(
|
||||||
|
payment_method.stripe_setup_intent_id)
|
||||||
|
|
||||||
|
# Card had been registered, fetching payment method.
|
||||||
|
payment_method_id = setup_intent["response_object"].payment_method
|
||||||
|
if payment_method_id:
|
||||||
|
payment_method.stripe_payment_method_id = payment_method_id
|
||||||
|
payment_method.save()
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'uuid': payment_method.uuid,
|
||||||
|
'activated': payment_method.active})
|
||||||
|
else:
|
||||||
|
error = 'Could not fetch payment method from stripe. Please try again.'
|
||||||
|
return Response({'error': error})
|
||||||
|
|
||||||
|
|
||||||
###
|
###
|
||||||
# Admin views.
|
# Admin views.
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue