Merge branch 'master' of code.ungleich.ch:uncloud/uncloud

This commit is contained in:
Nico Schottelius 2020-05-07 20:22:49 +02:00
commit 1b97fc8fc7
270 changed files with 314 additions and 150 deletions

0
uncloud_pay/__init__.py Normal file
View file

3
uncloud_pay/admin.py Normal file
View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

5
uncloud_pay/apps.py Normal file
View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class UncloudPayConfig(AppConfig):
name = 'uncloud_pay'

26
uncloud_pay/helpers.py Normal file
View file

@ -0,0 +1,26 @@
from functools import reduce
from datetime import datetime
from rest_framework import mixins
from rest_framework.viewsets import GenericViewSet
from django.utils import timezone
from calendar import monthrange
def beginning_of_month(year, month):
tz = timezone.get_current_timezone()
return datetime(year=year, month=month, day=1, tzinfo=tz)
def end_of_month(year, month):
(_, days) = monthrange(year, month)
tz = timezone.get_current_timezone()
return datetime(year=year, month=month, day=days,
hour=23, minute=59, second=59, tzinfo=tz)
class ProductViewSet(mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
GenericViewSet):
"""
A customer-facing viewset that provides default `create()`, `retrieve()`
and `list()`.
"""
pass

View file

@ -0,0 +1,31 @@
from django.core.management.base import BaseCommand
from uncloud_auth.models import User
from uncloud_pay.models import Order, Bill, PaymentMethod, get_balance_for_user
from datetime import timedelta
from django.utils import timezone
class Command(BaseCommand):
help = 'Generate bills and charge customers if necessary.'
def add_arguments(self, parser):
pass
def handle(self, *args, **options):
users = User.objects.all()
print("Processing {} users.".format(users.count()))
for user in users:
balance = get_balance_for_user(user)
if balance < 0:
print("User {} has negative balance ({}), charging.".format(user.username, balance))
payment_method = PaymentMethod.get_primary_for(user)
if payment_method != None:
amount_to_be_charged = abs(balance)
charge_ok = payment_method.charge(amount_to_be_charged)
if not charge_ok:
print("ERR: charging {} with method {} failed"
.format(user.username, payment_method.uuid)
)
else:
print("ERR: no payment method registered for {}".format(user.username))
print("=> Done.")

View file

@ -0,0 +1,35 @@
import logging
from django.core.management.base import BaseCommand
from uncloud_auth.models import User
from uncloud_pay.models import Order, Bill
from django.core.exceptions import ObjectDoesNotExist
from datetime import timedelta, date
from django.utils import timezone
from uncloud_pay.models import Bill
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = 'Generate bills and charge customers if necessary.'
def add_arguments(self, parser):
pass
# TODO: use logger.*
def handle(self, *args, **options):
# Iterate over all 'active' users.
# TODO: filter out inactive users.
users = User.objects.all()
print("Processing {} users.".format(users.count()))
for user in users:
now = timezone.now()
Bill.generate_for(
year=now.year,
month=now.month,
user=user)
# We're done for this round :-)
print("=> Done.")

View file

@ -0,0 +1,23 @@
from django.core.management.base import BaseCommand
from uncloud_auth.models import User
from uncloud_pay.models import Bill
from datetime import timedelta
from django.utils import timezone
class Command(BaseCommand):
help = 'Take action on overdue bills.'
def add_arguments(self, parser):
pass
def handle(self, *args, **options):
users = User.objects.all()
print("Processing {} users.".format(users.count()))
for user in users:
for bill in Bill.get_overdue_for(user):
print("/!\ Overdue bill for {}, {} with amount {}"
.format(user.username, bill.uuid, bill.amount))
# TODO: take action?
print("=> Done.")

View file

@ -0,0 +1,44 @@
from django.core.management.base import BaseCommand
from uncloud_pay.models import VATRate
import csv
class Command(BaseCommand):
help = '''Imports VAT Rates. Assume vat rates of format https://github.com/kdeldycke/vat-rates/blob/master/vat_rates.csv'''
def add_arguments(self, parser):
parser.add_argument('csv_file', nargs='+', type=str)
def handle(self, *args, **options):
try:
for c_file in options['csv_file']:
print("c_file = %s" % c_file)
with open(c_file, mode='r') as csv_file:
csv_reader = csv.DictReader(csv_file)
line_count = 0
for row in csv_reader:
if line_count == 0:
line_count += 1
obj, created = VATRate.objects.get_or_create(
start_date=row["start_date"],
stop_date=row["stop_date"] if row["stop_date"] is not "" else None,
territory_codes=row["territory_codes"],
currency_code=row["currency_code"],
rate=row["rate"],
rate_type=row["rate_type"],
description=row["description"]
)
if created:
self.stdout.write(self.style.SUCCESS(
'%s. %s - %s - %s - %s' % (
line_count,
obj.start_date,
obj.stop_date,
obj.territory_codes,
obj.rate
)
))
line_count+=1
except Exception as e:
print(" *** Error occurred. Details {}".format(str(e)))

View file

@ -0,0 +1,85 @@
# Generated by Django 3.0.3 on 2020-03-05 10:17
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('uncloud_auth', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Bill',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('creation_date', models.DateTimeField(auto_now_add=True)),
('starting_date', models.DateTimeField()),
('ending_date', models.DateTimeField()),
('due_date', models.DateField()),
('valid', models.BooleanField(default=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='Order',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('creation_date', models.DateTimeField(auto_now_add=True)),
('starting_date', models.DateTimeField(auto_now_add=True)),
('ending_date', models.DateTimeField(blank=True, null=True)),
('recurring_period', models.CharField(choices=[('ONCE', 'Onetime'), ('YEAR', 'Per Year'), ('MONTH', 'Per Month'), ('MINUTE', 'Per Minute'), ('DAY', 'Per Day'), ('HOUR', 'Per Hour'), ('SECOND', 'Per Second')], default='MONTH', max_length=32)),
('bill', models.ManyToManyField(blank=True, editable=False, to='uncloud_pay.Bill')),
('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='StripeCustomer',
fields=[
('owner', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)),
('stripe_id', models.CharField(max_length=32)),
],
),
migrations.CreateModel(
name='Payment',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('amount', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
('source', models.CharField(choices=[('wire', 'Wire Transfer'), ('stripe', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral'), ('unknown', 'Unknown')], default='unknown', max_length=256)),
('timestamp', models.DateTimeField(auto_now_add=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='OrderRecord',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('one_time_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
('recurring_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
('description', models.TextField()),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')),
],
),
migrations.CreateModel(
name='PaymentMethod',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('source', models.CharField(choices=[('stripe', 'Stripe'), ('unknown', 'Unknown')], default='stripe', max_length=256)),
('description', models.TextField()),
('primary', models.BooleanField(default=True)),
('stripe_card_id', models.CharField(blank=True, max_length=32, null=True)),
('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('owner', 'primary')},
},
),
]

View file

@ -0,0 +1,27 @@
# Generated by Django 3.0.3 on 2020-03-05 15:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0001_initial'),
]
operations = [
migrations.RenameField(
model_name='paymentmethod',
old_name='stripe_card_id',
new_name='stripe_payment_method_id',
),
migrations.AddField(
model_name='paymentmethod',
name='stripe_setup_intent_id',
field=models.CharField(blank=True, max_length=32, null=True),
),
migrations.AlterUniqueTogether(
name='paymentmethod',
unique_together=set(),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 3.0.3 on 2020-03-05 13:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0002_auto_20200305_1524'),
]
operations = [
migrations.AlterField(
model_name='paymentmethod',
name='primary',
field=models.BooleanField(default=False, editable=False),
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 3.0.5 on 2020-04-09 12:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0003_auto_20200305_1354'),
]
operations = [
migrations.AlterField(
model_name='order',
name='recurring_period',
field=models.CharField(choices=[('ONCE', 'Onetime'), ('YEAR', 'Per Year'), ('MONTH', 'Per Month'), ('MINUTE', 'Per Minute'), ('WEEK', 'Per Week'), ('DAY', 'Per Day'), ('HOUR', 'Per Hour'), ('SECOND', 'Per Second')], default='MONTH', max_length=32),
),
migrations.AlterField(
model_name='order',
name='starting_date',
field=models.DateTimeField(),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 3.0.5 on 2020-04-13 09:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0004_auto_20200409_1225'),
]
operations = [
migrations.AlterField(
model_name='order',
name='recurring_period',
field=models.CharField(choices=[('ONCE', 'Onetime'), ('YEAR', 'Per Year'), ('MONTH', 'Per Month'), ('WEEK', 'Per Week'), ('DAY', 'Per Day'), ('HOUR', 'Per Hour'), ('MINUTE', 'Per Minute'), ('SECOND', 'Per Second')], default='MONTH', max_length=32),
),
]

View file

@ -0,0 +1,31 @@
# Generated by Django 3.0.5 on 2020-04-15 10:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0005_auto_20200413_0924'),
]
operations = [
migrations.CreateModel(
name='VATRate',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('start_date', models.DateField(blank=True, null=True)),
('stop_date', models.DateField(blank=True, null=True)),
('territory_codes', models.TextField(blank=True, default='')),
('currency_code', models.CharField(max_length=10)),
('rate', models.FloatField()),
('rate_type', models.TextField(blank=True, default='')),
('description', models.TextField(blank=True, default='')),
],
),
migrations.AlterField(
model_name='order',
name='recurring_period',
field=models.CharField(choices=[('ONCE', 'Onetime'), ('YEAR', 'Per Year'), ('MONTH', 'Per Month'), ('WEEK', 'Per Week'), ('DAY', 'Per Day'), ('HOUR', 'Per Hour'), ('MINUTE', 'Per Minute'), ('SECOND', 'Per Second')], default='MONTH', max_length=32),
),
]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,19 @@
# Generated by Django 3.0.5 on 2020-05-02 19:21
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0007_auto_20200418_0737'),
]
operations = [
migrations.AlterField(
model_name='order',
name='starting_date',
field=models.DateTimeField(default=django.utils.timezone.now),
),
]

View file

@ -0,0 +1,47 @@
# Generated by Django 3.0.5 on 2020-05-02 20:47
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('uncloud_pay', '0008_auto_20200502_1921'),
]
operations = [
migrations.AddField(
model_name='order',
name='one_time_price',
field=models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='order',
name='recurring_price',
field=models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='order',
name='replaced_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='uncloud_pay.Order'),
),
migrations.CreateModel(
name='OrderTimothee',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('creation_date', models.DateTimeField(auto_now_add=True)),
('starting_date', models.DateTimeField(default=django.utils.timezone.now)),
('ending_date', models.DateTimeField(blank=True, null=True)),
('recurring_period', models.CharField(choices=[('ONCE', 'Onetime'), ('YEAR', 'Per Year'), ('MONTH', 'Per Month'), ('WEEK', 'Per Week'), ('DAY', 'Per Day'), ('HOUR', 'Per Hour'), ('MINUTE', 'Per Minute'), ('SECOND', 'Per Second')], default='MONTH', max_length=32)),
('bill', models.ManyToManyField(blank=True, editable=False, to='uncloud_pay.Bill')),
('billing_address', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.BillingAddress')),
('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 3.0.6 on 2020-05-07 10:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0009_auto_20200502_2047'),
]
operations = [
migrations.AddField(
model_name='order',
name='description',
field=models.TextField(default=''),
preserve_default=False,
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 3.0.6 on 2020-05-07 13:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0010_order_description'),
]
operations = [
migrations.AddField(
model_name='billingaddress',
name='organization',
field=models.CharField(default='', max_length=100),
preserve_default=False,
),
]

View file

1115
uncloud_pay/models.py Normal file

File diff suppressed because it is too large Load diff

115
uncloud_pay/serializers.py Normal file
View file

@ -0,0 +1,115 @@
from django.contrib.auth import get_user_model
from rest_framework import serializers
from uncloud_auth.serializers import UserSerializer
from .models import *
###
# Payments and Payment Methods.
class PaymentSerializer(serializers.ModelSerializer):
class Meta:
model = Payment
fields = '__all__'
class PaymentMethodSerializer(serializers.ModelSerializer):
stripe_card_last4 = serializers.IntegerField()
class Meta:
model = PaymentMethod
fields = ['uuid', 'source', 'description', 'primary', 'stripe_card_last4', 'active']
class UpdatePaymentMethodSerializer(serializers.ModelSerializer):
class Meta:
model = PaymentMethod
fields = ['description', 'primary']
class ChargePaymentMethodSerializer(serializers.Serializer):
amount = serializers.DecimalField(max_digits=10, decimal_places=2)
class CreatePaymentMethodSerializer(serializers.ModelSerializer):
please_visit = serializers.CharField(read_only=True)
class Meta:
model = PaymentMethod
fields = ['source', 'description', 'primary', 'please_visit']
###
# Orders & Products.
class OrderRecordSerializer(serializers.ModelSerializer):
class Meta:
model = OrderRecord
fields = ['one_time_price', 'recurring_price', 'description']
class OrderSerializer(serializers.ModelSerializer):
owner = serializers.PrimaryKeyRelatedField(queryset=get_user_model().objects.all())
def __init__(self, *args, **kwargs):
# Don't pass the 'fields' arg up to the superclass
admin = kwargs.pop('admin', None)
# Instantiate the superclass normally
super(OrderSerializer, self).__init__(*args, **kwargs)
# Only allows owner in admin mode.
if not admin:
self.fields.pop('owner')
def create(self, validated_data):
billing_address = BillingAddress.get_preferred_address_for(validated_data["owner"])
instance = Order(billing_address=billing_address, **validated_data)
instance.save()
return instance
def validate_owner(self, value):
if BillingAddress.get_preferred_address_for(value) == None:
raise serializers.ValidationError("Owner does not have a valid billing address.")
return value
class Meta:
model = Order
fields = ['uuid', 'owner', 'description', 'creation_date', 'starting_date', 'ending_date',
'bill', 'recurring_period', 'recurring_price', 'one_time_price']
###
# Bills
# TODO: remove magic numbers for decimal fields
class BillRecordSerializer(serializers.Serializer):
order = serializers.HyperlinkedRelatedField(
view_name='order-detail',
read_only=True)
description = serializers.CharField()
one_time_price = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
recurring_price = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
recurring_period = serializers.ChoiceField(choices=RecurringPeriod.choices)
recurring_count = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
vat_rate = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
vat_amount = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
amount = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
total = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
class BillingAddressSerializer(serializers.ModelSerializer):
class Meta:
model = BillingAddress
fields = ['uuid', 'organization', 'name', 'street', 'city', 'postal_code', 'country', 'vat_number']
class BillSerializer(serializers.ModelSerializer):
billing_address = BillingAddressSerializer(read_only=True)
records = BillRecordSerializer(many=True, read_only=True)
class Meta:
model = Bill
fields = ['uuid', 'reference', 'owner', 'amount', 'vat_amount', 'total',
'due_date', 'creation_date', 'starting_date', 'ending_date',
'records', 'final', 'billing_address']
# We do not want users to mutate the country / VAT number of an address, as it
# will change VAT on existing bills.
class UpdateBillingAddressSerializer(serializers.ModelSerializer):
class Meta:
model = BillingAddress
fields = ['uuid', 'street', 'city', 'postal_code']

114
uncloud_pay/stripe.py Normal file
View file

@ -0,0 +1,114 @@
import stripe
import stripe.error
import logging
from django.core.exceptions import ObjectDoesNotExist
from django.conf import settings
import uncloud_pay.models
# 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 = settings.STRIPE_KEY
# Helper (decorator) used to catch errors raised by stripe logic.
# Catch errors that should not be displayed to the end user, raise again.
def handle_stripe_error(f):
def handle_problems(*args, **kwargs):
response = {
'paid': False,
'response_object': None,
'error': None
}
common_message = "Currently it is not possible to make payments. Please try agin later."
try:
response_object = f(*args, **kwargs)
return response_object
except stripe.error.CardError as e:
# Since it's a decline, stripe.error.CardError will be caught
body = e.json_body
logging.error(str(e))
raise e # For error handling.
except stripe.error.RateLimitError:
logging.error("Too many requests made to the API too quickly.")
raise Exception(common_message)
except stripe.error.InvalidRequestError as e:
logging.error(str(e))
raise Exception('Invalid parameters.')
except stripe.error.AuthenticationError as e:
# Authentication with Stripe's API failed
# (maybe you changed API keys recently)
logging.error(str(e))
raise Exception(common_message)
except stripe.error.APIConnectionError as e:
logging.error(str(e))
raise Exception(common_message)
except stripe.error.StripeError as e:
# XXX: maybe send email
logging.error(str(e))
raise Exception(common_message)
except Exception as e:
# maybe send email
logging.error(str(e))
raise Exception(common_message)
return handle_problems
# Actual Stripe logic.
def public_api_key():
return settings.STRIPE_PUBLIC_KEY
def get_customer_id_for(user):
try:
# .get() raise if there is no matching entry.
return uncloud_pay.models.StripeCustomer.objects.get(owner=user).stripe_id
except ObjectDoesNotExist:
# No entry yet - making a new one.
try:
customer = create_customer(user.username, user.email)
uncloud_stripe_mapping = uncloud_pay.models.StripeCustomer.objects.create(
owner=user, stripe_id=customer.id)
return uncloud_stripe_mapping.stripe_id
except Exception as e:
return None
@handle_stripe_error
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)
@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)
@handle_stripe_error
def get_customer(customer_id):
return stripe.Customer.retrieve(customer_id)

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<title>Error</title>
<style>
#content {
width: 400px;
margin: auto;
}
</style>
</head>
<body>
<div id="content">
<h1>Error</h1>
<p>{{ error }}</p>
</div>
</body>
</html>

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>

228
uncloud_pay/tests.py Normal file
View file

@ -0,0 +1,228 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from datetime import datetime, date, timedelta
from .models import *
from uncloud_service.models import GenericServiceProduct
class BillingTestCase(TestCase):
def setUp(self):
self.user = get_user_model().objects.create(
username='jdoe',
email='john.doe@domain.tld')
self.billing_address = BillingAddress.objects.create(
owner=self.user,
street="unknown",
city="unknown",
postal_code="unknown")
def test_basic_monthly_billing(self):
one_time_price = 10
recurring_price = 20
description = "Test Product 1"
# Three months: full, full, partial.
starting_date = datetime.fromisoformat('2020-03-01')
ending_date = datetime.fromisoformat('2020-05-08')
# Create order to be billed.
order = Order.objects.create(
owner=self.user,
starting_date=starting_date,
ending_date=ending_date,
recurring_period=RecurringPeriod.PER_MONTH,
billing_address=self.billing_address)
order.add_record(one_time_price, recurring_price, description)
# Generate & check bill for first month: full recurring_price + setup.
first_month_bills = order.bills # Initial bill generated at order creation.
self.assertEqual(len(first_month_bills), 1)
self.assertEqual(first_month_bills[0].amount, one_time_price + recurring_price)
# Generate & check bill for second month: full recurring_price.
second_month_bills = Bill.generate_for(2020, 4, self.user)
self.assertEqual(len(second_month_bills), 1)
self.assertEqual(second_month_bills[0].amount, recurring_price)
# Generate & check bill for third and last month: partial recurring_price.
third_month_bills = Bill.generate_for(2020, 5, self.user)
self.assertEqual(len(third_month_bills), 1)
# 31 days in May.
self.assertEqual(float(third_month_bills[0].amount),
round((7/31) * recurring_price, AMOUNT_DECIMALS))
# Check that running Bill.generate_for() twice does not create duplicates.
self.assertEqual(len(Bill.generate_for(2020, 3, self.user)), 0)
def test_basic_yearly_billing(self):
one_time_price = 10
recurring_price = 150
description = "Test Product 1"
starting_date = datetime.fromisoformat('2020-03-31T08:05:23')
# Create order to be billed.
order = Order.objects.create(
owner=self.user,
starting_date=starting_date,
recurring_period=RecurringPeriod.PER_YEAR,
billing_address=self.billing_address)
order.add_record(one_time_price, recurring_price, description)
# Generate & check bill for first year: recurring_price + setup.
first_year_bills = order.bills # Initial bill generated at order creation.
self.assertEqual(len(first_year_bills), 1)
self.assertEqual(first_year_bills[0].starting_date.date(),
date.fromisoformat('2020-03-31'))
self.assertEqual(first_year_bills[0].ending_date.date(),
date.fromisoformat('2021-03-30'))
self.assertEqual(first_year_bills[0].amount,
recurring_price + one_time_price)
# Generate & check bill for second year: recurring_price.
second_year_bills = Bill.generate_for(2021, 3, self.user)
self.assertEqual(len(second_year_bills), 1)
self.assertEqual(second_year_bills[0].starting_date.date(),
date.fromisoformat('2021-03-31'))
self.assertEqual(second_year_bills[0].ending_date.date(),
date.fromisoformat('2022-03-30'))
self.assertEqual(second_year_bills[0].amount, recurring_price)
# Check that running Bill.generate_for() twice does not create duplicates.
self.assertEqual(len(Bill.generate_for(2020, 3, self.user)), 0)
self.assertEqual(len(Bill.generate_for(2020, 4, self.user)), 0)
self.assertEqual(len(Bill.generate_for(2020, 2, self.user)), 0)
self.assertEqual(len(Bill.generate_for(2021, 3, self.user)), 0)
def test_basic_hourly_billing(self):
one_time_price = 10
recurring_price = 1.4
description = "Test Product 1"
starting_date = datetime.fromisoformat('2020-03-31T08:05:23')
ending_date = datetime.fromisoformat('2020-04-01T11:13:32')
# Create order to be billed.
order = Order.objects.create(
owner=self.user,
starting_date=starting_date,
ending_date=ending_date,
recurring_period=RecurringPeriod.PER_HOUR,
billing_address=self.billing_address)
order.add_record(one_time_price, recurring_price, description)
# Generate & check bill for first month: recurring_price + setup.
first_month_bills = order.bills
self.assertEqual(len(first_month_bills), 1)
self.assertEqual(float(first_month_bills[0].amount),
round(16 * recurring_price, AMOUNT_DECIMALS) + one_time_price)
# Generate & check bill for first month: recurring_price.
second_month_bills = Bill.generate_for(2020, 4, self.user)
self.assertEqual(len(second_month_bills), 1)
self.assertEqual(float(second_month_bills[0].amount),
round(12 * recurring_price, AMOUNT_DECIMALS))
class ProductActivationTestCase(TestCase):
def setUp(self):
self.user = get_user_model().objects.create(
username='jdoe',
email='john.doe@domain.tld')
self.billing_address = BillingAddress.objects.create(
owner=self.user,
street="unknown",
city="unknown",
postal_code="unknown")
def test_product_activation(self):
starting_date = datetime.fromisoformat('2020-03-01')
order = Order.objects.create(
owner=self.user,
starting_date=starting_date,
recurring_period=RecurringPeriod.PER_MONTH,
billing_address=self.billing_address)
product = GenericServiceProduct(
custom_description="Test product",
custom_one_time_price=0,
custom_recurring_price=20,
owner=self.user,
order=order)
product.save()
# XXX: to be automated.
order.add_record(product.one_time_price, product.recurring_price, product.description)
# Validate initial state: must be awaiting payment.
self.assertEqual(product.status, UncloudStatus.AWAITING_PAYMENT)
# Pay initial bill, check that product is activated.
amount = product.order.bills[0].amount
payment = Payment(owner=self.user, amount=amount)
payment.save()
self.assertEqual(
GenericServiceProduct.objects.get(uuid=product.uuid).status,
UncloudStatus.PENDING
)
class BillingAddressTestCase(TestCase):
def setUp(self):
self.user = get_user_model().objects.create(
username='jdoe',
email='john.doe@domain.tld')
self.billing_address_01 = BillingAddress.objects.create(
owner=self.user,
street="unknown1",
city="unknown1",
postal_code="unknown1",
country="CH")
self.billing_address_02 = BillingAddress.objects.create(
owner=self.user,
street="unknown2",
city="unknown2",
postal_code="unknown2",
country="CH")
def test_billing_with_single_address(self):
# Create new orders somewhere in the past so that we do not encounter
# auto-created initial bills.
starting_date = datetime.fromisoformat('2020-03-01')
order_01 = Order.objects.create(
owner=self.user,
starting_date=starting_date,
recurring_period=RecurringPeriod.PER_MONTH,
billing_address=self.billing_address_01)
order_02 = Order.objects.create(
owner=self.user,
starting_date=starting_date,
recurring_period=RecurringPeriod.PER_MONTH,
billing_address=self.billing_address_01)
# We need a single bill since we work with a single address.
bills = Bill.generate_for(2020, 4, self.user)
self.assertEqual(len(bills), 1)
def test_billing_with_multiple_addresses(self):
# Create new orders somewhere in the past so that we do not encounter
# auto-created initial bills.
starting_date = datetime.fromisoformat('2020-03-01')
order_01 = Order.objects.create(
owner=self.user,
starting_date=starting_date,
recurring_period=RecurringPeriod.PER_MONTH,
billing_address=self.billing_address_01)
order_02 = Order.objects.create(
owner=self.user,
starting_date=starting_date,
recurring_period=RecurringPeriod.PER_MONTH,
billing_address=self.billing_address_02)
# We need different bills since we work with different addresses.
bills = Bill.generate_for(2020, 4, self.user)
self.assertEqual(len(bills), 2)

315
uncloud_pay/views.py Normal file
View file

@ -0,0 +1,315 @@
from django.shortcuts import render
from django.db import transaction
from django.contrib.auth import get_user_model
from rest_framework import viewsets, mixins, 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
from vat_validator import validate_vat, vies
from vat_validator.countries import EU_COUNTRY_CODES
from hardcopy import bytestring_to_pdf
from django.core.files.temp import NamedTemporaryFile
from django.http import FileResponse
from django.template.loader import render_to_string
import json
import logging
from .models import *
from .serializers import *
from datetime import datetime
from vat_validator import sanitize_vat
import uncloud_pay.stripe as uncloud_stripe
logger = logging.getLogger(__name__)
###
# Payments and Payment Methods.
class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = PaymentSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Payment.objects.filter(owner=self.request.user)
class OrderViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = OrderSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Order.objects.filter(owner=self.request.user)
class PaymentMethodViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticated]
def get_serializer_class(self):
if self.action == 'create':
return CreatePaymentMethodSerializer
elif self.action == 'update':
return UpdatePaymentMethodSerializer
elif self.action == 'charge':
return ChargePaymentMethodSerializer
else:
return PaymentMethodSerializer
def get_queryset(self):
return PaymentMethod.objects.filter(owner=self.request.user)
# XXX: Handling of errors is far from great down there.
@transaction.atomic
def create(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Set newly created method as primary if no other method is.
if PaymentMethod.get_primary_for(request.user) == None:
serializer.validated_data['primary'] = True
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)
try:
setup_intent = uncloud_stripe.create_setup_intent(customer_id)
except Exception as e:
return Response({'error': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR)
payment_method = PaymentMethod.objects.create(
owner=request.user,
stripe_setup_intent_id=setup_intent.id,
**serializer.validated_data)
# TODO: find a way to use reverse properly:
# https://www.django-rest-framework.org/api-guide/reverse/
path = "payment-method/{}/register-stripe-cc".format(
payment_method.uuid)
stripe_registration_url = reverse('api-root', request=request) + path
return Response({'please_visit': stripe_registration_url})
else:
serializer.save(owner=request.user, **serializer.validated_data)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def charge(self, request, pk=None):
payment_method = self.get_object()
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
amount = serializer.validated_data['amount']
try:
payment = payment_method.charge(amount)
output_serializer = PaymentSerializer(payment)
return Response(output_serializer.data)
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()
if payment_method.source != 'stripe':
return Response(
{'error': 'This is not a Stripe-based payment method.'},
template_name='error.html.j2')
if payment_method.active:
return Response(
{'error': 'This payment method is already active'},
template_name='error.html.j2')
try:
setup_intent = uncloud_stripe.get_setup_intent(
payment_method.stripe_setup_intent_id)
except Exception as e:
return Response(
{'error': str(e)},
template_name='error.html.j2')
# TODO: find a way to use reverse properly:
# https://www.django-rest-framework.org/api-guide/reverse/
callback_path= "payment-method/{}/activate-stripe-cc/".format(
payment_method.uuid)
callback = reverse('api-root', request=request) + callback_path
# Render stripe card registration form.
template_args = {
'client_secret': setup_intent.client_secret,
'stripe_pk': uncloud_stripe.public_api_key,
'callback': callback
}
return Response(template_args, template_name='stripe-payment.html.j2')
@action(detail=True, methods=['post'], url_path='activate-stripe-cc')
def activate_stripe_cc(self, request, pk=None):
payment_method = self.get_object()
try:
setup_intent = uncloud_stripe.get_setup_intent(
payment_method.stripe_setup_intent_id)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
# Card had been registered, fetching payment method.
print(setup_intent)
if setup_intent.payment_method:
payment_method.stripe_payment_method_id = setup_intent.payment_method
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})
@action(detail=True, methods=['post'], url_path='set-as-primary')
def set_as_primary(self, request, pk=None):
payment_method = self.get_object()
payment_method.set_as_primary_for(request.user)
serializer = self.get_serializer(payment_method)
return Response(serializer.data)
###
# Bills and Orders.
class BillViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = BillSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Bill.objects.filter(owner=self.request.user)
@action(detail=False, methods=['get'])
def unpaid(self, request):
serializer = self.get_serializer(
Bill.get_unpaid_for(self.request.user),
many=True)
return Response(serializer.data)
@action(detail=True, methods=['get'])
def download(self, *args, **kwargs):
bill = self.get_object()
output_file = NamedTemporaryFile()
bill_html = render_to_string("bill.html.j2", {'bill': bill})
bytestring_to_pdf(bill_html.encode('utf-8'), output_file)
response = FileResponse(output_file, content_type="application/pdf")
response['Content-Disposition'] = 'filename="{}_{}.pdf"'.format(
bill.reference, bill.uuid
)
return response
class OrderViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = OrderSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Order.objects.filter(owner=self.request.user)
class BillingAddressViewSet(mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet):
permission_classes = [permissions.IsAuthenticated]
def get_serializer_class(self):
if self.action == 'update':
return UpdateBillingAddressSerializer
else:
return BillingAddressSerializer
def get_queryset(self):
return self.request.user.billingaddress_set.all()
def create(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Validate VAT numbers.
country = serializer.validated_data["country"]
# We ignore empty VAT numbers.
if 'vat_number' in serializer.validated_data and serializer.validated_data["vat_number"] != "":
vat_number = serializer.validated_data["vat_number"]
if not validate_vat(country, vat_number):
return Response(
{'error': 'Malformed VAT number.'},
status=status.HTTP_400_BAD_REQUEST)
elif country in EU_COUNTRY_CODES:
# XXX: make a synchroneous call to a third patry API here might not be a good idea..
try:
vies_state = vies.check_vat(country, vat_number)
if not vies_state.valid:
return Response(
{'error': 'European VAT number does not exist in VIES.'},
status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
logger.warning(e)
return Response(
{'error': 'Could not validate EU VAT number against VIES. Try again later..'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR)
serializer.save(owner=request.user)
return Response(serializer.data)
###
# Admin stuff.
class AdminPaymentViewSet(viewsets.ModelViewSet):
serializer_class = PaymentSerializer
permission_classes = [permissions.IsAdminUser]
def get_queryset(self):
return Payment.objects.all()
def create(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save(timestamp=datetime.now())
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
# Bills are generated from orders and should not be created or updated by hand.
class AdminBillViewSet(BillViewSet):
serializer_class = BillSerializer
permission_classes = [permissions.IsAdminUser]
def get_queryset(self):
return Bill.objects.all()
@action(detail=False, methods=['get'])
def unpaid(self, request):
unpaid_bills = []
# XXX: works but we can do better than number of users + 1 SQL requests...
for user in get_user_model().objects.all():
unpaid_bills = unpaid_bills + Bill.get_unpaid_for(self.request.user)
serializer = self.get_serializer(unpaid_bills, many=True)
return Response(serializer.data)
class AdminOrderViewSet(mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.CreateModelMixin,
viewsets.GenericViewSet):
serializer_class = OrderSerializer
permission_classes = [permissions.IsAdminUser]
def get_serializer(self, *args, **kwargs):
return self.serializer_class(*args, **kwargs, admin=True)
def get_queryset(self):
return Order.objects.all()