[credit card] implement payment

This commit is contained in:
Nico Schottelius 2020-12-29 01:43:33 +01:00
parent e225bf1cc0
commit 1b06d8ee03
16 changed files with 290 additions and 64 deletions

View File

@ -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/address', payviews.BillingAddressViewSet, basename='billingaddress')
router.register(r'v1/my/bill', payviews.BillViewSet, basename='bill') 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/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') router.register(r'v1/my/payment-method', payviews.PaymentMethodViewSet, basename='payment-method')
# admin/staff urls # admin/staff urls
router.register(r'v1/admin/bill', payviews.AdminBillViewSet, basename='admin/bill') 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/order', payviews.AdminOrderViewSet, basename='admin/order')
router.register(r'v1/admin/vmhost', vmviews.VMHostViewSet) router.register(r'v1/admin/vmhost', vmviews.VMHostViewSet)
router.register(r'v1/admin/vmcluster', vmviews.VMClusterViewSet) router.register(r'v1/admin/vmcluster', vmviews.VMClusterViewSet)
@ -74,6 +74,7 @@ router.register(r'v2/net/wireguardvpnsizes', netviews.WireGuardVPNSizes, basenam
# Payment related # Payment related
router.register(r'v2/payment/credit-card', payviews.CreditCardViewSet, basename='credit-card') router.register(r'v2/payment/credit-card', payviews.CreditCardViewSet, basename='credit-card')
router.register(r'v2/payment/payment', payviews.PaymentViewSet, basename='payment')
urlpatterns = [ urlpatterns = [
@ -83,16 +84,13 @@ urlpatterns = [
path('openapi', get_schema_view( path('openapi', get_schema_view(
title="uncloud", title="uncloud",
description="uncloud API", description="uncloud API",
version="1.0.0" version="2.0.0"
), name='openapi-schema'), ), name='openapi-schema'),
# web/ = stuff to view in the browser path('admin/', admin.site.urls),
# path('web/vpn/create/', netviews.WireGuardVPNCreateView.as_view(), name="vpncreate"),
path('login/', authviews.LoginView.as_view(), name="login"), path('login/', authviews.LoginView.as_view(), name="login"),
path('logout/', authviews.LogoutView.as_view(), name="logout"), path('logout/', authviews.LogoutView.as_view(), name="logout"),
path('admin/', admin.site.urls),
path('cc/reg/', payviews.RegisterCard.as_view(), name="cc_register"), path('cc/reg/', payviews.RegisterCard.as_view(), name="cc_register"),
path('', uncloudviews.UncloudIndex.as_view(), name="uncloudindex"), path('', uncloudviews.UncloudIndex.as_view(), name="uncloudindex"),

View File

@ -10,10 +10,8 @@ from django.core.files.temp import NamedTemporaryFile
from django.http import FileResponse from django.http import FileResponse
from django.template.loader import render_to_string from django.template.loader import render_to_string
from uncloud_pay.models import * from uncloud_pay.models import *
class BillRecordInline(admin.TabularInline): class BillRecordInline(admin.TabularInline):
model = BillRecord model = BillRecord
@ -85,8 +83,17 @@ class BillAdmin(admin.ModelAdmin):
admin.site.register(Bill, BillAdmin) admin.site.register(Bill, BillAdmin)
admin.site.register(ProductToRecurringPeriod)
admin.site.register(Product, ProductAdmin) 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) admin.site.register(m)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -64,8 +64,8 @@ class Currency(models.TextChoices):
Possible currencies to be billed Possible currencies to be billed
""" """
CHF = 'CHF', _('Swiss Franc') CHF = 'CHF', _('Swiss Franc')
EUR = 'EUR', _('Euro') # EUR = 'EUR', _('Euro')
USD = 'USD', _('US Dollar') # USD = 'USD', _('US Dollar')
def get_balance_for_user(user): def get_balance_for_user(user):
@ -93,28 +93,30 @@ class StripeCustomer(models.Model):
class StripeCreditCard(models.Model): class StripeCreditCard(models.Model):
owner = models.OneToOneField( get_user_model(), owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
on_delete=models.CASCADE)
card_name = models.CharField(null=False, max_length=128, default="My credit card") card_name = models.CharField(null=False, max_length=128, default="My credit card")
card_id = models.CharField(null=False, max_length=32) card_id = models.CharField(null=False, max_length=32)
last4 = models.CharField(null=False, max_length=4) last4 = models.CharField(null=False, max_length=4)
brand = models.CharField(null=False, max_length=64) brand = models.CharField(null=False, max_length=64)
expiry_date = models.DateField(null=False) 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): def __str__(self):
return f"{self.card_name}: {self.brand} {self.last4} ({self.expiry_date})" return f"{self.card_name}: {self.brand} {self.last4} ({self.expiry_date})"
###
# Payments and Payment Methods.
class Payment(models.Model): class Payment(models.Model):
owner = models.ForeignKey(get_user_model(), owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
on_delete=models.CASCADE)
amount = models.DecimalField( amount = models.DecimalField(
default=0.0,
max_digits=AMOUNT_MAX_DIGITS, max_digits=AMOUNT_MAX_DIGITS,
decimal_places=AMOUNT_DECIMALS, decimal_places=AMOUNT_DECIMALS,
validators=[MinValueValidator(0)]) validators=[MinValueValidator(0)])
@ -128,21 +130,18 @@ class Payment(models.Model):
('unknown', 'Unknown') ('unknown', 'Unknown')
), ),
default='unknown') default='unknown')
timestamp = models.DateTimeField(editable=False, auto_now_add=True)
# We override save() in order to active products awaiting payment. timestamp = models.DateTimeField(default=timezone.now)
def save(self, *args, **kwargs):
# _state.adding is switched to false after super(...) call.
being_created = self._state.adding
unpaid_bills_before_payment = Bill.get_unpaid_for(self.owner) currency = models.CharField(max_length=32, choices=Currency.choices, default=Currency.CHF)
super(Payment, self).save(*args, **kwargs) # Save payment in DB.
unpaid_bills_after_payment = Bill.get_unpaid_for(self.owner)
newly_paid_bills = list( external_reference = models.CharField(max_length=256, default="", null=True, blank=True)
set(unpaid_bills_before_payment) - set(unpaid_bills_after_payment))
for bill in newly_paid_bills: def __str__(self):
bill.activate_products() return f"{self.amount}{self.currency} from {self.owner} via {self.source} on {self.timestamp}"
###
# Payments and Payment Methods.
class PaymentMethod(models.Model): class PaymentMethod(models.Model):

View File

@ -4,17 +4,33 @@ from uncloud_auth.serializers import UserSerializer
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .models import * from .models import *
import uncloud_pay.stripe as uncloud_stripe
### ###
# 2020-12 Checked code # 2020-12 Checked code
class StripeCreditCardSerializer(serializers.ModelSerializer): class StripeCreditCardSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = StripeCreditCard model = StripeCreditCard
exclude = ['card_id', "owner" ] exclude = [ "card_id", "owner" ]
read_only_fields = [ "last4", "brand", "expiry_date" ] 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. # Payments and Payment Methods.
class PaymentSerializer(serializers.ModelSerializer):
class Meta:
model = Payment
fields = '__all__'
class UpdatePaymentMethodSerializer(serializers.ModelSerializer): class UpdatePaymentMethodSerializer(serializers.ModelSerializer):
class Meta: class Meta:

View File

@ -3,7 +3,7 @@ import stripe.error
import logging import logging
import datetime import datetime
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model 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): def get_payment_method(payment_method_id):
return stripe.PaymentMethod.retrieve(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 @handle_stripe_error
def create_customer(name, email): def create_customer(name, email):
return stripe.Customer.create(name=name, email=email) return stripe.Customer.create(name=name, email=email)
@ -111,7 +97,6 @@ def get_customer_cards(customer_id):
customer=customer_id, customer=customer_id,
type="card", type="card",
) )
print(stripe_cards["data"])
for stripe_card in stripe_cards["data"]: for stripe_card in stripe_cards["data"]:
card = {} card = {}
@ -129,7 +114,21 @@ def sync_cards_for_user(user):
customer_id = get_customer_id_for(user) customer_id = get_customer_id_for(user)
cards = get_customer_cards(customer_id) 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: 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'], StripeCreditCard.objects.get_or_create(card_id=card['id'],
owner = user, owner = user,
defaults = { defaults = {
@ -137,6 +136,36 @@ def sync_cards_for_user(user):
'brand': card['brand'], 'brand': card['brand'],
'expiry_date': datetime.date(card['year'], 'expiry_date': datetime.date(card['year'],
card['month'], 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,
)

View File

@ -63,8 +63,7 @@
} else { } else {
// Return to API on success. // Return to API on success.
document.getElementById("ungleichmessage").innerHTML document.getElementById("ungleichmessage").innerHTML
= "Registered credit card with = "Registered credit card with Stripe."
Stripe. <a href="/">Return to the main page.</a>"
} }
}); });
}); });

View File

@ -68,17 +68,18 @@ class CreditCardViewSet(mixins.RetrieveModelMixin,
return StripeCreditCard.objects.filter(owner=self.request.user) return StripeCreditCard.objects.filter(owner=self.request.user)
class PaymentViewSet(viewsets.ModelViewSet):
###
# Payments and Payment Methods.
class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = PaymentSerializer serializer_class = PaymentSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
def get_queryset(self): def get_queryset(self):
return Payment.objects.filter(owner=self.request.user) return Payment.objects.filter(owner=self.request.user)
###
# Payments and Payment Methods.
class OrderViewSet(viewsets.ReadOnlyModelViewSet): class OrderViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = OrderSerializer serializer_class = OrderSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]