Merge branch 'master' of code.ungleich.ch:uncloud/uncloud
This commit is contained in:
commit
1b97fc8fc7
270 changed files with 314 additions and 150 deletions
0
uncloud_pay/__init__.py
Normal file
0
uncloud_pay/__init__.py
Normal file
3
uncloud_pay/admin.py
Normal file
3
uncloud_pay/admin.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
5
uncloud_pay/apps.py
Normal file
5
uncloud_pay/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UncloudPayConfig(AppConfig):
|
||||
name = 'uncloud_pay'
|
||||
26
uncloud_pay/helpers.py
Normal file
26
uncloud_pay/helpers.py
Normal 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
|
||||
31
uncloud_pay/management/commands/charge-negative-balance.py
Normal file
31
uncloud_pay/management/commands/charge-negative-balance.py
Normal 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.")
|
||||
35
uncloud_pay/management/commands/generate-bills.py
Normal file
35
uncloud_pay/management/commands/generate-bills.py
Normal 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.")
|
||||
23
uncloud_pay/management/commands/handle-overdue-bills.py
Normal file
23
uncloud_pay/management/commands/handle-overdue-bills.py
Normal 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.")
|
||||
44
uncloud_pay/management/commands/import-vat-rates.py
Normal file
44
uncloud_pay/management/commands/import-vat-rates.py
Normal 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)))
|
||||
85
uncloud_pay/migrations/0001_initial.py
Normal file
85
uncloud_pay/migrations/0001_initial.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
27
uncloud_pay/migrations/0002_auto_20200305_1524.py
Normal file
27
uncloud_pay/migrations/0002_auto_20200305_1524.py
Normal 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(),
|
||||
),
|
||||
]
|
||||
18
uncloud_pay/migrations/0003_auto_20200305_1354.py
Normal file
18
uncloud_pay/migrations/0003_auto_20200305_1354.py
Normal 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),
|
||||
),
|
||||
]
|
||||
23
uncloud_pay/migrations/0004_auto_20200409_1225.py
Normal file
23
uncloud_pay/migrations/0004_auto_20200409_1225.py
Normal 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(),
|
||||
),
|
||||
]
|
||||
18
uncloud_pay/migrations/0005_auto_20200413_0924.py
Normal file
18
uncloud_pay/migrations/0005_auto_20200413_0924.py
Normal 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),
|
||||
),
|
||||
]
|
||||
31
uncloud_pay/migrations/0006_auto_20200415_1003.py
Normal file
31
uncloud_pay/migrations/0006_auto_20200415_1003.py
Normal 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),
|
||||
),
|
||||
]
|
||||
29
uncloud_pay/migrations/0006_billingaddress.py
Normal file
29
uncloud_pay/migrations/0006_billingaddress.py
Normal file
File diff suppressed because one or more lines are too long
42
uncloud_pay/migrations/0007_auto_20200418_0737.py
Normal file
42
uncloud_pay/migrations/0007_auto_20200418_0737.py
Normal file
File diff suppressed because one or more lines are too long
19
uncloud_pay/migrations/0008_auto_20200502_1921.py
Normal file
19
uncloud_pay/migrations/0008_auto_20200502_1921.py
Normal 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),
|
||||
),
|
||||
]
|
||||
47
uncloud_pay/migrations/0009_auto_20200502_2047.py
Normal file
47
uncloud_pay/migrations/0009_auto_20200502_2047.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
19
uncloud_pay/migrations/0010_order_description.py
Normal file
19
uncloud_pay/migrations/0010_order_description.py
Normal 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,
|
||||
),
|
||||
]
|
||||
19
uncloud_pay/migrations/0011_billingaddress_organization.py
Normal file
19
uncloud_pay/migrations/0011_billingaddress_organization.py
Normal 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,
|
||||
),
|
||||
]
|
||||
0
uncloud_pay/migrations/__init__.py
Normal file
0
uncloud_pay/migrations/__init__.py
Normal file
1115
uncloud_pay/models.py
Normal 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
115
uncloud_pay/serializers.py
Normal 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
114
uncloud_pay/stripe.py
Normal 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)
|
||||
1080
uncloud_pay/templates/bill.html.j2
Normal file
1080
uncloud_pay/templates/bill.html.j2
Normal file
File diff suppressed because one or more lines are too long
18
uncloud_pay/templates/error.html.j2
Normal file
18
uncloud_pay/templates/error.html.j2
Normal 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>
|
||||
76
uncloud_pay/templates/stripe-payment.html.j2
Normal file
76
uncloud_pay/templates/stripe-payment.html.j2
Normal 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
228
uncloud_pay/tests.py
Normal 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
315
uncloud_pay/views.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue