[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/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"),

View file

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

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

View file

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

View file

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

View file

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

View file

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