Replace legacy Stripe Charge API by Payment{setup, intent}

This commit is contained in:
fnux 2020-03-05 10:23:34 +01:00
parent cf17373b3f
commit 929211162d
10 changed files with 250 additions and 50 deletions

View file

@ -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')

View 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(),
),
]

View 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 = [
]

View 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),
),
]

View 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',
),
]

View file

@ -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.

View file

@ -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']
### ###

View file

@ -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):

View 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>

View file

@ -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.