From 1b06d8ee0353ca1b4ef97afa930f38a553b3d05c Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 29 Dec 2020 01:43:33 +0100 Subject: [PATCH] [credit card] implement payment --- uncloud/urls.py | 12 ++-- uncloud_pay/admin.py | 15 +++-- .../migrations/0002_auto_20201228_2244.py | 24 +++++++ .../migrations/0003_auto_20201228_2256.py | 28 +++++++++ .../0004_stripecreditcard_active.py | 18 ++++++ .../migrations/0005_auto_20201228_2335.py | 18 ++++++ .../migrations/0006_auto_20201228_2337.py | 21 +++++++ .../migrations/0007_auto_20201228_2338.py | 17 +++++ .../0008_payment_external_reference.py | 18 ++++++ .../migrations/0009_auto_20201229_0037.py | 18 ++++++ .../migrations/0010_auto_20201229_0042.py | 19 ++++++ uncloud_pay/models.py | 45 +++++++------ uncloud_pay/serializers.py | 24 +++++-- uncloud_pay/stripe.py | 63 ++++++++++++++----- .../uncloud_pay/register_stripe.html | 3 +- uncloud_pay/views.py | 11 ++-- 16 files changed, 290 insertions(+), 64 deletions(-) create mode 100644 uncloud_pay/migrations/0002_auto_20201228_2244.py create mode 100644 uncloud_pay/migrations/0003_auto_20201228_2256.py create mode 100644 uncloud_pay/migrations/0004_stripecreditcard_active.py create mode 100644 uncloud_pay/migrations/0005_auto_20201228_2335.py create mode 100644 uncloud_pay/migrations/0006_auto_20201228_2337.py create mode 100644 uncloud_pay/migrations/0007_auto_20201228_2338.py create mode 100644 uncloud_pay/migrations/0008_payment_external_reference.py create mode 100644 uncloud_pay/migrations/0009_auto_20201229_0037.py create mode 100644 uncloud_pay/migrations/0010_auto_20201229_0042.py diff --git a/uncloud/urls.py b/uncloud/urls.py index f163136..3ee5988 100644 --- a/uncloud/urls.py +++ b/uncloud/urls.py @@ -47,12 +47,12 @@ router.register(r'v1/service/generic', serviceviews.GenericServiceProductViewSet router.register(r'v1/my/address', payviews.BillingAddressViewSet, basename='billingaddress') router.register(r'v1/my/bill', payviews.BillViewSet, basename='bill') router.register(r'v1/my/order', payviews.OrderViewSet, basename='order') -router.register(r'v1/my/payment', payviews.PaymentViewSet, basename='payment') +#router.register(r'v1/my/payment', payviews.PaymentViewSet, basename='payment') router.register(r'v1/my/payment-method', payviews.PaymentMethodViewSet, basename='payment-method') # admin/staff urls router.register(r'v1/admin/bill', payviews.AdminBillViewSet, basename='admin/bill') -router.register(r'v1/admin/payment', payviews.AdminPaymentViewSet, basename='admin/payment') +#router.register(r'v1/admin/payment', payviews.AdminPaymentViewSet, basename='admin/payment') router.register(r'v1/admin/order', payviews.AdminOrderViewSet, basename='admin/order') router.register(r'v1/admin/vmhost', vmviews.VMHostViewSet) router.register(r'v1/admin/vmcluster', vmviews.VMClusterViewSet) @@ -74,6 +74,7 @@ router.register(r'v2/net/wireguardvpnsizes', netviews.WireGuardVPNSizes, basenam # Payment related router.register(r'v2/payment/credit-card', payviews.CreditCardViewSet, basename='credit-card') +router.register(r'v2/payment/payment', payviews.PaymentViewSet, basename='payment') urlpatterns = [ @@ -83,16 +84,13 @@ urlpatterns = [ path('openapi', get_schema_view( title="uncloud", description="uncloud API", - version="1.0.0" + version="2.0.0" ), name='openapi-schema'), - # web/ = stuff to view in the browser -# path('web/vpn/create/', netviews.WireGuardVPNCreateView.as_view(), name="vpncreate"), + path('admin/', admin.site.urls), path('login/', authviews.LoginView.as_view(), name="login"), path('logout/', authviews.LogoutView.as_view(), name="logout"), - path('admin/', admin.site.urls), - path('cc/reg/', payviews.RegisterCard.as_view(), name="cc_register"), path('', uncloudviews.UncloudIndex.as_view(), name="uncloudindex"), diff --git a/uncloud_pay/admin.py b/uncloud_pay/admin.py index eb82fb7..d8b09da 100644 --- a/uncloud_pay/admin.py +++ b/uncloud_pay/admin.py @@ -10,10 +10,8 @@ from django.core.files.temp import NamedTemporaryFile from django.http import FileResponse from django.template.loader import render_to_string - from uncloud_pay.models import * - class BillRecordInline(admin.TabularInline): model = BillRecord @@ -85,8 +83,17 @@ class BillAdmin(admin.ModelAdmin): admin.site.register(Bill, BillAdmin) -admin.site.register(ProductToRecurringPeriod) admin.site.register(Product, ProductAdmin) -for m in [ Order, BillRecord, BillingAddress, RecurringPeriod, VATRate, StripeCustomer, StripeCreditCard ]: +for m in [ + BillRecord, + BillingAddress, + Order, + Payment, + ProductToRecurringPeriod, + RecurringPeriod, + StripeCreditCard, + StripeCustomer, + VATRate, +]: admin.site.register(m) diff --git a/uncloud_pay/migrations/0002_auto_20201228_2244.py b/uncloud_pay/migrations/0002_auto_20201228_2244.py new file mode 100644 index 0000000..4665553 --- /dev/null +++ b/uncloud_pay/migrations/0002_auto_20201228_2244.py @@ -0,0 +1,24 @@ +# Generated by Django 3.1 on 2020-12-28 22:44 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='payment', + name='currency', + field=models.CharField(choices=[('CHF', 'Swiss Franc'), ('EUR', 'Euro'), ('USD', 'US Dollar')], default='CHF', max_length=32), + ), + migrations.AlterField( + model_name='payment', + name='amount', + field=models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]), + ), + ] diff --git a/uncloud_pay/migrations/0003_auto_20201228_2256.py b/uncloud_pay/migrations/0003_auto_20201228_2256.py new file mode 100644 index 0000000..b516bd5 --- /dev/null +++ b/uncloud_pay/migrations/0003_auto_20201228_2256.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1 on 2020-12-28 22:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0002_auto_20201228_2244'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='currency', + field=models.CharField(choices=[('CHF', 'Swiss Franc')], default='CHF', max_length=32), + ), + migrations.AlterField( + model_name='payment', + name='currency', + field=models.CharField(choices=[('CHF', 'Swiss Franc')], default='CHF', max_length=32), + ), + migrations.AlterField( + model_name='product', + name='currency', + field=models.CharField(choices=[('CHF', 'Swiss Franc')], default='CHF', max_length=32), + ), + ] diff --git a/uncloud_pay/migrations/0004_stripecreditcard_active.py b/uncloud_pay/migrations/0004_stripecreditcard_active.py new file mode 100644 index 0000000..3fb8015 --- /dev/null +++ b/uncloud_pay/migrations/0004_stripecreditcard_active.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2020-12-28 23:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0003_auto_20201228_2256'), + ] + + operations = [ + migrations.AddField( + model_name='stripecreditcard', + name='active', + field=models.BooleanField(default=True), + ), + ] diff --git a/uncloud_pay/migrations/0005_auto_20201228_2335.py b/uncloud_pay/migrations/0005_auto_20201228_2335.py new file mode 100644 index 0000000..814752e --- /dev/null +++ b/uncloud_pay/migrations/0005_auto_20201228_2335.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2020-12-28 23:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0004_stripecreditcard_active'), + ] + + operations = [ + migrations.AlterField( + model_name='stripecreditcard', + name='active', + field=models.BooleanField(default=False), + ), + ] diff --git a/uncloud_pay/migrations/0006_auto_20201228_2337.py b/uncloud_pay/migrations/0006_auto_20201228_2337.py new file mode 100644 index 0000000..a164767 --- /dev/null +++ b/uncloud_pay/migrations/0006_auto_20201228_2337.py @@ -0,0 +1,21 @@ +# Generated by Django 3.1 on 2020-12-28 23:37 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('uncloud_pay', '0005_auto_20201228_2335'), + ] + + operations = [ + migrations.AlterField( + model_name='stripecreditcard', + name='owner', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/uncloud_pay/migrations/0007_auto_20201228_2338.py b/uncloud_pay/migrations/0007_auto_20201228_2338.py new file mode 100644 index 0000000..315a74b --- /dev/null +++ b/uncloud_pay/migrations/0007_auto_20201228_2338.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1 on 2020-12-28 23:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0006_auto_20201228_2337'), + ] + + operations = [ + migrations.AddConstraint( + model_name='stripecreditcard', + constraint=models.UniqueConstraint(condition=models.Q(active=True), fields=('owner',), name='one_active_card_per_user'), + ), + ] diff --git a/uncloud_pay/migrations/0008_payment_external_reference.py b/uncloud_pay/migrations/0008_payment_external_reference.py new file mode 100644 index 0000000..0de20b6 --- /dev/null +++ b/uncloud_pay/migrations/0008_payment_external_reference.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2020-12-29 00:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0007_auto_20201228_2338'), + ] + + operations = [ + migrations.AddField( + model_name='payment', + name='external_reference', + field=models.CharField(default='', max_length=256), + ), + ] diff --git a/uncloud_pay/migrations/0009_auto_20201229_0037.py b/uncloud_pay/migrations/0009_auto_20201229_0037.py new file mode 100644 index 0000000..fc195e4 --- /dev/null +++ b/uncloud_pay/migrations/0009_auto_20201229_0037.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2020-12-29 00:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0008_payment_external_reference'), + ] + + operations = [ + migrations.AlterField( + model_name='payment', + name='external_reference', + field=models.CharField(blank=True, default='', max_length=256, null=True), + ), + ] diff --git a/uncloud_pay/migrations/0010_auto_20201229_0042.py b/uncloud_pay/migrations/0010_auto_20201229_0042.py new file mode 100644 index 0000000..6dd6a60 --- /dev/null +++ b/uncloud_pay/migrations/0010_auto_20201229_0042.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1 on 2020-12-29 00:42 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0009_auto_20201229_0037'), + ] + + operations = [ + migrations.AlterField( + model_name='payment', + name='timestamp', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + ] diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index abf769c..0c880c3 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -64,8 +64,8 @@ class Currency(models.TextChoices): Possible currencies to be billed """ CHF = 'CHF', _('Swiss Franc') - EUR = 'EUR', _('Euro') - USD = 'USD', _('US Dollar') +# EUR = 'EUR', _('Euro') +# USD = 'USD', _('US Dollar') def get_balance_for_user(user): @@ -93,28 +93,30 @@ class StripeCustomer(models.Model): class StripeCreditCard(models.Model): - owner = models.OneToOneField( get_user_model(), - on_delete=models.CASCADE) + owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) card_name = models.CharField(null=False, max_length=128, default="My credit card") card_id = models.CharField(null=False, max_length=32) last4 = models.CharField(null=False, max_length=4) brand = models.CharField(null=False, max_length=64) expiry_date = models.DateField(null=False) + active = models.BooleanField(default=False) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=['owner'], + condition=Q(active=True), + name='one_active_card_per_user') + ] + def __str__(self): return f"{self.card_name}: {self.brand} {self.last4} ({self.expiry_date})" - -### -# Payments and Payment Methods. - class Payment(models.Model): - owner = models.ForeignKey(get_user_model(), - on_delete=models.CASCADE) + owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) amount = models.DecimalField( - default=0.0, max_digits=AMOUNT_MAX_DIGITS, decimal_places=AMOUNT_DECIMALS, validators=[MinValueValidator(0)]) @@ -128,21 +130,18 @@ class Payment(models.Model): ('unknown', 'Unknown') ), default='unknown') - timestamp = models.DateTimeField(editable=False, auto_now_add=True) - # We override save() in order to active products awaiting payment. - def save(self, *args, **kwargs): - # _state.adding is switched to false after super(...) call. - being_created = self._state.adding + timestamp = models.DateTimeField(default=timezone.now) - unpaid_bills_before_payment = Bill.get_unpaid_for(self.owner) - super(Payment, self).save(*args, **kwargs) # Save payment in DB. - unpaid_bills_after_payment = Bill.get_unpaid_for(self.owner) + currency = models.CharField(max_length=32, choices=Currency.choices, default=Currency.CHF) - newly_paid_bills = list( - set(unpaid_bills_before_payment) - set(unpaid_bills_after_payment)) - for bill in newly_paid_bills: - bill.activate_products() + external_reference = models.CharField(max_length=256, default="", null=True, blank=True) + + def __str__(self): + return f"{self.amount}{self.currency} from {self.owner} via {self.source} on {self.timestamp}" + +### +# Payments and Payment Methods. class PaymentMethod(models.Model): diff --git a/uncloud_pay/serializers.py b/uncloud_pay/serializers.py index 84a23fd..14cac0b 100644 --- a/uncloud_pay/serializers.py +++ b/uncloud_pay/serializers.py @@ -4,17 +4,33 @@ from uncloud_auth.serializers import UserSerializer from django.utils.translation import gettext_lazy as _ from .models import * +import uncloud_pay.stripe as uncloud_stripe ### # 2020-12 Checked code - class StripeCreditCardSerializer(serializers.ModelSerializer): class Meta: model = StripeCreditCard - exclude = ['card_id', "owner" ] + exclude = [ "card_id", "owner" ] read_only_fields = [ "last4", "brand", "expiry_date" ] +class PaymentSerializer(serializers.ModelSerializer): + owner = serializers.HiddenField(default=serializers.CurrentUserDefault()) + + class Meta: + model = Payment + fields = '__all__' + read_only_fields = [ "external_reference", "source", "timestamp" ] + + def validate(self, data): + payment_intent = uncloud_stripe.charge_customer(data['owner'], + data['amount']) + + data["external_reference"] = payment_intent["id"] + data["source"] = "stripe" + + return data ################################################################################ @@ -23,10 +39,6 @@ class StripeCreditCardSerializer(serializers.ModelSerializer): ### # Payments and Payment Methods. -class PaymentSerializer(serializers.ModelSerializer): - class Meta: - model = Payment - fields = '__all__' class UpdatePaymentMethodSerializer(serializers.ModelSerializer): class Meta: diff --git a/uncloud_pay/stripe.py b/uncloud_pay/stripe.py index 5b3bb00..ed95c82 100644 --- a/uncloud_pay/stripe.py +++ b/uncloud_pay/stripe.py @@ -3,7 +3,7 @@ import stripe.error import logging import datetime -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.conf import settings from django.contrib.auth import get_user_model @@ -80,20 +80,6 @@ def get_setup_intent(setup_intent_id): def get_payment_method(payment_method_id): return stripe.PaymentMethod.retrieve(payment_method_id) -@handle_stripe_error -def charge_customer(amount, customer_id, card_id): - # Amount is in CHF but stripes requires smallest possible unit. - # https://stripe.com/docs/api/payment_intents/create#create_payment_intent-amount - adjusted_amount = int(amount * 100) - 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): return stripe.Customer.create(name=name, email=email) @@ -111,7 +97,6 @@ def get_customer_cards(customer_id): customer=customer_id, type="card", ) - print(stripe_cards["data"]) for stripe_card in stripe_cards["data"]: card = {} @@ -129,7 +114,21 @@ def sync_cards_for_user(user): customer_id = get_customer_id_for(user) cards = get_customer_cards(customer_id) + active_cards = StripeCreditCard.objects.filter(owner=user, + active=True) + + if len(active_cards) > 0: + has_active_card = True + else: + has_active_card = False + for card in cards: + active = False + + if not has_active_card: + active = True + has_active_card = True + StripeCreditCard.objects.get_or_create(card_id=card['id'], owner = user, defaults = { @@ -137,6 +136,36 @@ def sync_cards_for_user(user): 'brand': card['brand'], 'expiry_date': datetime.date(card['year'], card['month'], - 1) + 1), + 'active': active } ) + +@handle_stripe_error +def charge_customer(user, amount, currency='CHF'): + # Amount is in CHF but stripes requires smallest possible unit. + # https://stripe.com/docs/api/payment_intents/create#create_payment_intent-amount + # FIXME: might need to be adjusted for other currencies + + if currency == 'CHF': + adjusted_amount = int(amount * 100) + else: + return Exception("Programming error: unsupported currency") + + try: + card = StripeCreditCard.objects.get(owner=user, + active=True) + + except StripeCreditCard.DoesNotExist: + raise ValidationError("No active credit card - cannot create payment") + + customer_id = get_customer_id_for(user) + + return stripe.PaymentIntent.create( + amount=adjusted_amount, + currency=currency, + customer=customer_id, + payment_method=card.card_id, + off_session=True, + confirm=True, + ) diff --git a/uncloud_pay/templates/uncloud_pay/register_stripe.html b/uncloud_pay/templates/uncloud_pay/register_stripe.html index 76265fa..82aca74 100644 --- a/uncloud_pay/templates/uncloud_pay/register_stripe.html +++ b/uncloud_pay/templates/uncloud_pay/register_stripe.html @@ -63,8 +63,7 @@ } else { // Return to API on success. document.getElementById("ungleichmessage").innerHTML - = "Registered credit card with - Stripe. Return to the main page." + = "Registered credit card with Stripe." } }); }); diff --git a/uncloud_pay/views.py b/uncloud_pay/views.py index 246e922..48c24a8 100644 --- a/uncloud_pay/views.py +++ b/uncloud_pay/views.py @@ -68,17 +68,18 @@ class CreditCardViewSet(mixins.RetrieveModelMixin, return StripeCreditCard.objects.filter(owner=self.request.user) - -### -# Payments and Payment Methods. - -class PaymentViewSet(viewsets.ReadOnlyModelViewSet): +class PaymentViewSet(viewsets.ModelViewSet): serializer_class = PaymentSerializer permission_classes = [permissions.IsAuthenticated] def get_queryset(self): return Payment.objects.filter(owner=self.request.user) + +### +# Payments and Payment Methods. + + class OrderViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = OrderSerializer permission_classes = [permissions.IsAuthenticated]