diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index d7ee153..e42bb7e 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -61,7 +61,6 @@ router.register(r'payment-method', payviews.PaymentMethodViewSet, basename='paym router.register(r'bill', payviews.BillViewSet, basename='bill') router.register(r'order', payviews.OrderViewSet, basename='order') router.register(r'payment', payviews.PaymentViewSet, basename='payment') -router.register(r'payment-method', payviews.PaymentMethodViewSet, basename='payment-methods') # VMs router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vm') diff --git a/uncloud/uncloud_pay/migrations/0017_auto_20200304_1723.py b/uncloud/uncloud_pay/migrations/0017_auto_20200304_1723.py new file mode 100644 index 0000000..3321e66 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0017_auto_20200304_1723.py @@ -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(), + ), + ] diff --git a/uncloud/uncloud_pay/migrations/0018_auto_20200305_0819.py b/uncloud/uncloud_pay/migrations/0018_auto_20200305_0819.py new file mode 100644 index 0000000..e0f9087 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0018_auto_20200305_0819.py @@ -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 = [ + ] diff --git a/uncloud/uncloud_pay/migrations/0019_auto_20200305_0851.py b/uncloud/uncloud_pay/migrations/0019_auto_20200305_0851.py new file mode 100644 index 0000000..d8a7c22 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0019_auto_20200305_0851.py @@ -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), + ), + ] diff --git a/uncloud/uncloud_pay/migrations/0020_auto_20200305_0911.py b/uncloud/uncloud_pay/migrations/0020_auto_20200305_0911.py new file mode 100644 index 0000000..9e1b677 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0020_auto_20200305_0911.py @@ -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', + ), + ] diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 17afbcb..1e54b9e 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -119,27 +119,31 @@ class PaymentMethod(models.Model): primary = models.BooleanField(default=True) # 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 def stripe_card_last4(self): - if self.source == 'stripe': - card_request = uncloud_pay.stripe.get_card( - StripeCustomer.objects.get(owner=self.owner).stripe_id, - self.stripe_card_id) - if card_request['error'] == None: - return card_request['response_object']['last4'] - else: - return None + if self.source == 'stripe' and self.active: + payment_method = uncloud_pay.stripe.get_payment_method( + self.stripe_payment_method_id) + return payment_method.card.last4 else: 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): if amount > 0: # Make sure we don't charge negative amount by errors... if self.source == 'stripe': 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: payment = Payment(owner=self.owner, source=self.source, amount=amount) payment.save() # TODO: Check return status @@ -163,7 +167,8 @@ class PaymentMethod(models.Model): return None class Meta: - unique_together = [['owner', 'primary']] + #API_keyunique_together = [['owner', 'primary']] + pass ### # Bills & Payments. diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 60ddc75..328dec1 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -29,7 +29,7 @@ class PaymentMethodSerializer(serializers.ModelSerializer): class Meta: model = PaymentMethod - fields = ['uuid', 'source', 'description', 'primary', 'stripe_card_last4'] + fields = ['uuid', 'source', 'description', 'primary', 'stripe_card_last4', 'active'] class ChargePaymentMethodSerializer(serializers.Serializer): amount = serializers.DecimalField(max_digits=10, decimal_places=2) @@ -41,11 +41,10 @@ class CreditCardSerializer(serializers.Serializer): cvc = serializers.IntegerField() class CreatePaymentMethodSerializer(serializers.ModelSerializer): - credit_card = CreditCardSerializer() - + please_visit = serializers.CharField(read_only=True) class Meta: model = PaymentMethod - fields = ['source', 'description', 'primary', 'credit_card'] + fields = ['source', 'description', 'primary', 'please_visit'] ### # Orders & Products. diff --git a/uncloud/uncloud_pay/stripe.py b/uncloud/uncloud_pay/stripe.py index 4f28d94..72399c8 100644 --- a/uncloud/uncloud_pay/stripe.py +++ b/uncloud/uncloud_pay/stripe.py @@ -10,6 +10,10 @@ import uncloud.secrets # Static stripe configuration used below. 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 # Helper (decorator) used to catch errors raised by stripe logic. @@ -82,6 +86,9 @@ class CreditCard(): # Actual Stripe logic. +def public_api_key(): + return uncloud.settings.STRIPE_PUBLIC_KEY + def get_customer_id_for(user): try: # .get() raise if there is no matching entry. @@ -99,15 +106,17 @@ def get_customer_id_for(user): return None @handle_stripe_error -def create_card(customer_id, credit_card): - return stripe.Customer.create_source( - customer_id, - card={ - 'number': credit_card.number, - 'exp_month': credit_card.exp_month, - 'exp_year': credit_card.exp_year, - 'cvc': credit_card.cvc - }) +def create_setup_intent(customer_id): + return stripe.SetupIntent.create(customer=customer_id) + +@handle_stripe_error +def get_setup_intent(setup_intent_id): + return stripe.SetupIntent.retrieve(setup_intent_id) + +def get_payment_method(payment_method_id): + return stripe.PaymentMethod.retrieve(payment_method_id) + +## Legacy @handle_stripe_error def get_card(customer_id, card_id): @@ -116,13 +125,16 @@ def get_card(customer_id, card_id): @handle_stripe_error def charge_customer(amount, customer_id, card_id): # 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) - return stripe.Charge.create( - amount=adjusted_amount, - currency=CURRENCY, - customer=customer_id, - source=card_id) + return stripe.PaymentIntent.create( + amount=adjusted_amount, + currency=CURRENCY, + customer=customer_id, + payment_method=card_id, + off_session=True, + confirm=True, + ) @handle_stripe_error def create_customer(name, email): diff --git a/uncloud/uncloud_pay/templates/stripe-payment.html.j2 b/uncloud/uncloud_pay/templates/stripe-payment.html.j2 new file mode 100644 index 0000000..6c59740 --- /dev/null +++ b/uncloud/uncloud_pay/templates/stripe-payment.html.j2 @@ -0,0 +1,76 @@ + + + + Stripe Card Registration + + + + + + + + +
+

Registering Stripe Credit Card

+ + + +
+
+ +
+ + +
+
+ + + + + + + + diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index 57c284d..fb02774 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -1,9 +1,12 @@ from django.shortcuts import render from django.db import transaction 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.decorators import action +from rest_framework.reverse import reverse +from rest_framework.decorators import renderer_classes import json @@ -66,7 +69,6 @@ class PaymentMethodViewSet(viewsets.ModelViewSet): else: return PaymentMethodSerializer - def get_queryset(self): return PaymentMethod.objects.filter(owner=self.request.user) @@ -75,29 +77,32 @@ class PaymentMethodViewSet(viewsets.ModelViewSet): def create(self, request): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) + payment_method = PaymentMethod.objects.create(owner=request.user, **serializer.validated_data) - # Retrieve Stripe customer ID for user. - customer_id = uncloud_stripe.get_customer_id_for(request.user) - if customer_id == None: - return Response( + if serializer.validated_data['source'] == "stripe": + # Retrieve Stripe customer ID for user. + customer_id = uncloud_stripe.get_customer_id_for(request.user) + if customer_id == None: + return Response( {'error': 'Could not resolve customer stripe ID.'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - # Register card under stripe customer. - credit_card = uncloud_stripe.CreditCard(**serializer.validated_data.pop('credit_card')) - card_request = uncloud_stripe.create_card(customer_id, credit_card) - if card_request['error']: - return Response({'stripe_error': card_request['error']}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - card_id = card_request['response_object']['id'] + # TODO: handle error + setup_intent = uncloud_stripe.create_setup_intent(customer_id) + payment_method = PaymentMethod.objects.create( + owner=request.user, + stripe_setup_intent_id=setup_intent['response_object']['id'], + **serializer.validated_data) - # Save payment method locally. - serializer.validated_data['stripe_card_id'] = card_request['response_object']['id'] - payment_method = PaymentMethod.objects.create(owner=request.user, **serializer.validated_data) + # TODO: find a way to use reverse properly: + # https://www.django-rest-framework.org/api-guide/reverse/ + 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 - # request. - output_serializer = PaymentMethodSerializer(payment_method) - return Response(output_serializer.data) + return Response(serializer.data) @action(detail=True, methods=['post']) def charge(self, request, pk=None): @@ -112,6 +117,39 @@ class PaymentMethodViewSet(viewsets.ModelViewSet): except Exception as e: 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.