forked from uncloud/uncloud
		
	Merge branch 'master' into HEAD
This commit is contained in:
		
				commit
				
					
						3588ae88f9
					
				
			
		
					 258 changed files with 12598 additions and 436 deletions
				
			
		
							
								
								
									
										0
									
								
								uncloud_django_based/uncloud/uncloud_pay/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								uncloud_django_based/uncloud/uncloud_pay/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
								
								
									
										3
									
								
								uncloud_django_based/uncloud/uncloud_pay/admin.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								uncloud_django_based/uncloud/uncloud_pay/admin.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,3 @@
 | 
			
		|||
from django.contrib import admin
 | 
			
		||||
 | 
			
		||||
# Register your models here.
 | 
			
		||||
							
								
								
									
										5
									
								
								uncloud_django_based/uncloud/uncloud_pay/apps.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								uncloud_django_based/uncloud/uncloud_pay/apps.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
from django.apps import AppConfig
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UncloudPayConfig(AppConfig):
 | 
			
		||||
    name = 'uncloud_pay'
 | 
			
		||||
							
								
								
									
										26
									
								
								uncloud_django_based/uncloud/uncloud_pay/helpers.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								uncloud_django_based/uncloud/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
 | 
			
		||||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
 | 
			
		||||
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)
 | 
			
		||||
            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.")
 | 
			
		||||
| 
						 | 
				
			
			@ -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.")
 | 
			
		||||
| 
						 | 
				
			
			@ -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.")
 | 
			
		||||
| 
						 | 
				
			
			@ -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')},
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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(),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										560
									
								
								uncloud_django_based/uncloud/uncloud_pay/models.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										560
									
								
								uncloud_django_based/uncloud/uncloud_pay/models.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,560 @@
 | 
			
		|||
from django.db import models
 | 
			
		||||
from django.db.models import Q
 | 
			
		||||
from django.contrib.auth import get_user_model
 | 
			
		||||
from django.core.validators import MinValueValidator
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from django.core.exceptions import ObjectDoesNotExist
 | 
			
		||||
 | 
			
		||||
import uuid
 | 
			
		||||
import logging
 | 
			
		||||
from functools import reduce
 | 
			
		||||
from math import ceil
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
from calendar import monthrange
 | 
			
		||||
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
import uncloud_pay.stripe
 | 
			
		||||
from uncloud_pay.helpers import beginning_of_month, end_of_month
 | 
			
		||||
from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS
 | 
			
		||||
from uncloud.models import UncloudModel, UncloudStatus
 | 
			
		||||
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
import decimal
 | 
			
		||||
 | 
			
		||||
# Define DecimalField properties, used to represent amounts of money.
 | 
			
		||||
AMOUNT_MAX_DIGITS=10
 | 
			
		||||
AMOUNT_DECIMALS=2
 | 
			
		||||
 | 
			
		||||
# FIXME: check why we need +1 here.
 | 
			
		||||
decimal.getcontext().prec = AMOUNT_DECIMALS + 1
 | 
			
		||||
 | 
			
		||||
# Used to generate bill due dates.
 | 
			
		||||
BILL_PAYMENT_DELAY=timedelta(days=10)
 | 
			
		||||
 | 
			
		||||
# Initialize logger.
 | 
			
		||||
logger = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types
 | 
			
		||||
class RecurringPeriod(models.TextChoices):
 | 
			
		||||
    ONE_TIME   = 'ONCE', _('Onetime')
 | 
			
		||||
    PER_YEAR   = 'YEAR', _('Per Year')
 | 
			
		||||
    PER_MONTH  = 'MONTH', _('Per Month')
 | 
			
		||||
    PER_MINUTE = 'MINUTE', _('Per Minute')
 | 
			
		||||
    PER_WEEK   = 'WEEK', _('Per Week')
 | 
			
		||||
    PER_DAY    = 'DAY', _('Per Day')
 | 
			
		||||
    PER_HOUR   = 'HOUR', _('Per Hour')
 | 
			
		||||
    PER_SECOND = 'SECOND', _('Per Second')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_balance_for_user(user):
 | 
			
		||||
    bills = reduce(
 | 
			
		||||
            lambda acc, entry: acc + entry.total,
 | 
			
		||||
            Bill.objects.filter(owner=user),
 | 
			
		||||
            0)
 | 
			
		||||
    payments = reduce(
 | 
			
		||||
            lambda acc, entry: acc + entry.amount,
 | 
			
		||||
            Payment.objects.filter(owner=user),
 | 
			
		||||
            0)
 | 
			
		||||
    return payments - bills
 | 
			
		||||
 | 
			
		||||
class StripeCustomer(models.Model):
 | 
			
		||||
    owner = models.OneToOneField( get_user_model(),
 | 
			
		||||
            primary_key=True,
 | 
			
		||||
            on_delete=models.CASCADE)
 | 
			
		||||
    stripe_id = models.CharField(max_length=32)
 | 
			
		||||
 | 
			
		||||
###
 | 
			
		||||
# Payments and Payment Methods.
 | 
			
		||||
 | 
			
		||||
class Payment(models.Model):
 | 
			
		||||
    uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
 | 
			
		||||
 | 
			
		||||
    owner = models.ForeignKey(get_user_model(),
 | 
			
		||||
            on_delete=models.CASCADE)
 | 
			
		||||
 | 
			
		||||
    amount = models.DecimalField(
 | 
			
		||||
            default=0.0,
 | 
			
		||||
            max_digits=AMOUNT_MAX_DIGITS,
 | 
			
		||||
            decimal_places=AMOUNT_DECIMALS,
 | 
			
		||||
            validators=[MinValueValidator(0)])
 | 
			
		||||
 | 
			
		||||
    source = models.CharField(max_length=256,
 | 
			
		||||
                              choices = (
 | 
			
		||||
                                  ('wire', 'Wire Transfer'),
 | 
			
		||||
                                  ('stripe', 'Stripe'),
 | 
			
		||||
                                  ('voucher', 'Voucher'),
 | 
			
		||||
                                  ('referral', 'Referral'),
 | 
			
		||||
                                  ('unknown', 'Unknown')
 | 
			
		||||
                              ),
 | 
			
		||||
                              default='unknown')
 | 
			
		||||
    timestamp = models.DateTimeField(editable=False, auto_now_add=True)
 | 
			
		||||
 | 
			
		||||
    # WIP prepaid and service activation logic by fnux.
 | 
			
		||||
    ## We override save() in order to active products awaiting payment.
 | 
			
		||||
    #def save(self, *args, **kwargs):
 | 
			
		||||
    #    # TODO: only run activation logic on creation, not on update.
 | 
			
		||||
    #    unpaid_bills_before_payment = Bill.get_unpaid_for(self.owner)
 | 
			
		||||
    #    super(Payment, self).save(*args, **kwargs) # Save payment in DB.
 | 
			
		||||
    #    unpaid_bills_after_payment = Bill.get_unpaid_for(self.owner)
 | 
			
		||||
 | 
			
		||||
    #    newly_paid_bills = list(
 | 
			
		||||
    #            set(unpaid_bills_before_payment) - set(unpaid_bills_after_payment))
 | 
			
		||||
    #    for bill in newly_paid_bills:
 | 
			
		||||
    #        bill.activate_orders()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PaymentMethod(models.Model):
 | 
			
		||||
    uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
 | 
			
		||||
    owner = models.ForeignKey(get_user_model(),
 | 
			
		||||
            on_delete=models.CASCADE,
 | 
			
		||||
            editable=False)
 | 
			
		||||
    source = models.CharField(max_length=256,
 | 
			
		||||
            choices = (
 | 
			
		||||
                ('stripe', 'Stripe'),
 | 
			
		||||
                ('unknown', 'Unknown'),
 | 
			
		||||
                ),
 | 
			
		||||
            default='stripe')
 | 
			
		||||
    description = models.TextField()
 | 
			
		||||
    primary = models.BooleanField(default=False, editable=False)
 | 
			
		||||
 | 
			
		||||
    # Only used for "Stripe" source
 | 
			
		||||
    stripe_payment_method_id = models.CharField(max_length=32, blank=True, null=True)
 | 
			
		||||
    stripe_setup_intent_id = models.CharField(max_length=32, blank=True, null=True)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def stripe_card_last4(self):
 | 
			
		||||
        if self.source == 'stripe' and self.active:
 | 
			
		||||
            payment_method = uncloud_pay.stripe.get_payment_method(
 | 
			
		||||
                    self.stripe_payment_method_id)
 | 
			
		||||
            return payment_method.card.last4
 | 
			
		||||
        else:
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def active(self):
 | 
			
		||||
        if self.source == 'stripe' and self.stripe_payment_method_id != None:
 | 
			
		||||
            return True
 | 
			
		||||
        else:
 | 
			
		||||
            return False
 | 
			
		||||
 | 
			
		||||
    def charge(self, amount):
 | 
			
		||||
        if not self.active:
 | 
			
		||||
            raise Exception('This payment method is inactive.')
 | 
			
		||||
 | 
			
		||||
        if amount < 0: # Make sure we don't charge negative amount by errors...
 | 
			
		||||
            raise Exception('Cannot charge negative amount.')
 | 
			
		||||
 | 
			
		||||
        if self.source == 'stripe':
 | 
			
		||||
            stripe_customer = StripeCustomer.objects.get(owner=self.owner).stripe_id
 | 
			
		||||
            stripe_payment = uncloud_pay.stripe.charge_customer(
 | 
			
		||||
                    amount, stripe_customer, self.stripe_payment_method_id)
 | 
			
		||||
            if 'paid' in stripe_payment and stripe_payment['paid'] == False:
 | 
			
		||||
                raise Exception(stripe_payment['error'])
 | 
			
		||||
            else:
 | 
			
		||||
                payment = Payment.objects.create(
 | 
			
		||||
                        owner=self.owner, source=self.source, amount=amount)
 | 
			
		||||
 | 
			
		||||
                return payment
 | 
			
		||||
        else:
 | 
			
		||||
            raise Exception('This payment method is unsupported/cannot be charged.')
 | 
			
		||||
 | 
			
		||||
    def set_as_primary_for(self, user):
 | 
			
		||||
        methods = PaymentMethod.objects.filter(owner=user, primary=True)
 | 
			
		||||
        for method in methods:
 | 
			
		||||
            print(method)
 | 
			
		||||
            method.primary = False
 | 
			
		||||
            method.save()
 | 
			
		||||
 | 
			
		||||
        self.primary = True
 | 
			
		||||
        self.save()
 | 
			
		||||
 | 
			
		||||
    def get_primary_for(user):
 | 
			
		||||
        methods = PaymentMethod.objects.filter(owner=user)
 | 
			
		||||
        for method in methods:
 | 
			
		||||
            # Do we want to do something with non-primary method?
 | 
			
		||||
            if method.active and method.primary:
 | 
			
		||||
                return method
 | 
			
		||||
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        # TODO: limit to one primary method per user.
 | 
			
		||||
        # unique_together is no good since it won't allow more than one
 | 
			
		||||
        # non-primary method.
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
###
 | 
			
		||||
# Bills.
 | 
			
		||||
 | 
			
		||||
class Bill(models.Model):
 | 
			
		||||
    uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
 | 
			
		||||
    owner = models.ForeignKey(get_user_model(),
 | 
			
		||||
            on_delete=models.CASCADE)
 | 
			
		||||
 | 
			
		||||
    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)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def reference(self):
 | 
			
		||||
        return "{}-{}".format(
 | 
			
		||||
                self.owner.username,
 | 
			
		||||
                self.creation_date.strftime("%Y-%m-%d-%H%M"))
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def records(self):
 | 
			
		||||
        bill_records = []
 | 
			
		||||
        orders = Order.objects.filter(bill=self)
 | 
			
		||||
        for order in orders:
 | 
			
		||||
            for order_record in order.records:
 | 
			
		||||
                bill_record = BillRecord(self, order_record)
 | 
			
		||||
                bill_records.append(bill_record)
 | 
			
		||||
 | 
			
		||||
        return bill_records
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def total(self):
 | 
			
		||||
        return reduce(lambda acc, record: acc + record.amount, self.records, 0)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def final(self):
 | 
			
		||||
        # A bill is final when its ending date is passed.
 | 
			
		||||
        return self.ending_date < timezone.now()
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def generate_for(year, month, user):
 | 
			
		||||
        # /!\ We exclusively work on the specified year and month.
 | 
			
		||||
        generated_bills = []
 | 
			
		||||
 | 
			
		||||
        # Default values for next bill (if any).
 | 
			
		||||
        starting_date=beginning_of_month(year, month)
 | 
			
		||||
        ending_date=end_of_month(year, month)
 | 
			
		||||
        creation_date=timezone.now()
 | 
			
		||||
 | 
			
		||||
        # Select all orders active on the request period (i.e. starting on or after starting_date).
 | 
			
		||||
        orders = Order.objects.filter(
 | 
			
		||||
                Q(ending_date__gte=starting_date) | Q(ending_date__isnull=True),
 | 
			
		||||
                owner=user)
 | 
			
		||||
 | 
			
		||||
        # Check if there is already a bill covering the order and period pair:
 | 
			
		||||
        #   * Get latest bill by ending_date: previous_bill.ending_date
 | 
			
		||||
        #   * For monthly bills: if previous_bill.ending_date is before
 | 
			
		||||
        #     (next_bill) ending_date, a new bill has to be generated.
 | 
			
		||||
        #   * For yearly bill: if previous_bill.ending_date is on working
 | 
			
		||||
        #     month, generate new bill.
 | 
			
		||||
        unpaid_orders = { 'monthly_or_less': [], 'yearly': {}}
 | 
			
		||||
        for order in orders:
 | 
			
		||||
            try:
 | 
			
		||||
                previous_bill = order.bill.latest('ending_date')
 | 
			
		||||
            except ObjectDoesNotExist:
 | 
			
		||||
                previous_bill = None
 | 
			
		||||
 | 
			
		||||
            # FIXME: control flow is confusing in this block.
 | 
			
		||||
            if order.recurring_period == RecurringPeriod.PER_YEAR:
 | 
			
		||||
                # We ignore anything smaller than a day in here.
 | 
			
		||||
                next_yearly_bill_start_on = None
 | 
			
		||||
                if previous_bill == None:
 | 
			
		||||
                    next_yearly_bill_start_on = order.starting_date
 | 
			
		||||
                elif previous_bill.ending_date <= ending_date:
 | 
			
		||||
                    next_yearly_bill_start_on = (previous_bill.ending_date + timedelta(days=1))
 | 
			
		||||
 | 
			
		||||
                # Store for bill generation. One bucket per day of month with a starting bill.
 | 
			
		||||
                # bucket is a reference here, no need to reassign.
 | 
			
		||||
                if next_yearly_bill_start_on:
 | 
			
		||||
                    # We want to group orders by date but keep using datetimes.
 | 
			
		||||
                    next_yearly_bill_start_on = next_yearly_bill_start_on.replace(
 | 
			
		||||
                            minute=0, hour=0, second=0, microsecond=0)
 | 
			
		||||
                    bucket = unpaid_orders['yearly'].get(next_yearly_bill_start_on)
 | 
			
		||||
                    if bucket == None:
 | 
			
		||||
                        unpaid_orders['yearly'][next_yearly_bill_start_on] = [order]
 | 
			
		||||
                    else:
 | 
			
		||||
                        unpaid_orders['yearly'][next_yearly_bill_start_on] = bucket + [order]
 | 
			
		||||
            else:
 | 
			
		||||
                if previous_bill == None or previous_bill.ending_date <= ending_date:
 | 
			
		||||
                    unpaid_orders['monthly_or_less'].append(order)
 | 
			
		||||
 | 
			
		||||
        # Handle working month's billing.
 | 
			
		||||
        if len(unpaid_orders['monthly_or_less']) > 0:
 | 
			
		||||
            # TODO: PREPAID billing is not supported yet.
 | 
			
		||||
            prepaid_due_date = min(creation_date, starting_date) + BILL_PAYMENT_DELAY
 | 
			
		||||
            postpaid_due_date = max(creation_date, ending_date) + BILL_PAYMENT_DELAY
 | 
			
		||||
 | 
			
		||||
            next_monthly_bill = Bill.objects.create(owner=user,
 | 
			
		||||
                    creation_date=creation_date,
 | 
			
		||||
                    starting_date=starting_date, # FIXME: this is a hack!
 | 
			
		||||
                    ending_date=ending_date,
 | 
			
		||||
                    due_date=postpaid_due_date)
 | 
			
		||||
 | 
			
		||||
            # It is not possible to register many-to-many relationship before
 | 
			
		||||
            # the two end-objects are saved in database.
 | 
			
		||||
            for order in unpaid_orders['monthly_or_less']:
 | 
			
		||||
                order.bill.add(next_monthly_bill)
 | 
			
		||||
 | 
			
		||||
            logger.info("Generated monthly bill {} (amount: {}) for user {}."
 | 
			
		||||
                    .format(next_monthly_bill.uuid, next_monthly_bill.total, user))
 | 
			
		||||
 | 
			
		||||
            # Add to output.
 | 
			
		||||
            generated_bills.append(next_monthly_bill)
 | 
			
		||||
 | 
			
		||||
        # Handle yearly bills starting on working month.
 | 
			
		||||
        if len(unpaid_orders['yearly']) > 0:
 | 
			
		||||
 | 
			
		||||
            # For every starting date, generate new bill.
 | 
			
		||||
            for next_yearly_bill_start_on in unpaid_orders['yearly']:
 | 
			
		||||
                # No postpaid for yearly payments.
 | 
			
		||||
                prepaid_due_date = min(creation_date, next_yearly_bill_start_on) + BILL_PAYMENT_DELAY
 | 
			
		||||
                # Bump by one year, remove one day.
 | 
			
		||||
                ending_date = next_yearly_bill_start_on.replace(
 | 
			
		||||
                        year=next_yearly_bill_start_on.year+1) - timedelta(days=1)
 | 
			
		||||
 | 
			
		||||
                next_yearly_bill = Bill.objects.create(owner=user,
 | 
			
		||||
                        creation_date=creation_date,
 | 
			
		||||
                        starting_date=next_yearly_bill_start_on,
 | 
			
		||||
                        ending_date=ending_date,
 | 
			
		||||
                        due_date=prepaid_due_date)
 | 
			
		||||
 | 
			
		||||
                # It is not possible to register many-to-many relationship before
 | 
			
		||||
                # the two end-objects are saved in database.
 | 
			
		||||
                for order in unpaid_orders['yearly'][next_yearly_bill_start_on]:
 | 
			
		||||
                    order.bill.add(next_yearly_bill)
 | 
			
		||||
 | 
			
		||||
                logger.info("Generated yearly bill {} (amount: {}) for user {}."
 | 
			
		||||
                        .format(next_yearly_bill.uuid, next_yearly_bill.total, user))
 | 
			
		||||
 | 
			
		||||
                # Add to output.
 | 
			
		||||
                generated_bills.append(next_yearly_bill)
 | 
			
		||||
 | 
			
		||||
        # Return generated (monthly + yearly) bills.
 | 
			
		||||
        return generated_bills
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def get_unpaid_for(user):
 | 
			
		||||
        balance = get_balance_for(user)
 | 
			
		||||
        unpaid_bills = []
 | 
			
		||||
        # No unpaid bill if balance is positive.
 | 
			
		||||
        if balance >= 0:
 | 
			
		||||
            return []
 | 
			
		||||
        else:
 | 
			
		||||
            bills = Bill.objects.filter(
 | 
			
		||||
                    owner=user,
 | 
			
		||||
                    due_date__lt=timezone.now()
 | 
			
		||||
                    ).order_by('-creation_date')
 | 
			
		||||
 | 
			
		||||
            # Amount to be paid by the customer.
 | 
			
		||||
            unpaid_balance = abs(balance)
 | 
			
		||||
            for bill in bills:
 | 
			
		||||
                if  unpaid_balance < 0:
 | 
			
		||||
                    break
 | 
			
		||||
 | 
			
		||||
                    unpaid_balance -= bill.amount
 | 
			
		||||
                    unpaid_bills.append(bill)
 | 
			
		||||
 | 
			
		||||
            return unpaid_bills
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def get_overdue_for(user):
 | 
			
		||||
        unpaid_bills = Bill.get_unpaid_for(user)
 | 
			
		||||
        return list(filter(lambda bill: bill.due_date > timezone.now(), unpaid_bills))
 | 
			
		||||
 | 
			
		||||
class BillRecord():
 | 
			
		||||
    """
 | 
			
		||||
    Entry of a bill, dynamically generated from order records.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def __init__(self, bill, order_record):
 | 
			
		||||
        self.bill = bill
 | 
			
		||||
        self.order = order_record.order
 | 
			
		||||
        self.recurring_price = order_record.recurring_price
 | 
			
		||||
        self.recurring_period = order_record.recurring_period
 | 
			
		||||
        self.description = order_record.description
 | 
			
		||||
 | 
			
		||||
        if self.order.starting_date >= self.bill.starting_date:
 | 
			
		||||
            self.one_time_price = order_record.one_time_price
 | 
			
		||||
        else:
 | 
			
		||||
            self.one_time_price = 0
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def recurring_count(self):
 | 
			
		||||
        # Compute billing delta.
 | 
			
		||||
        billed_until = self.bill.ending_date
 | 
			
		||||
        if self.order.ending_date != None and self.order.ending_date <= self.bill.ending_date:
 | 
			
		||||
            billed_until = self.order.ending_date
 | 
			
		||||
 | 
			
		||||
        billed_from = self.bill.starting_date
 | 
			
		||||
        if self.order.starting_date > self.bill.starting_date:
 | 
			
		||||
            billed_from = self.order.starting_date
 | 
			
		||||
 | 
			
		||||
        if billed_from > billed_until:
 | 
			
		||||
            # TODO: think about and check edge cases. This should not be
 | 
			
		||||
            # possible.
 | 
			
		||||
            raise Exception('Impossible billing delta!')
 | 
			
		||||
 | 
			
		||||
        billed_delta = billed_until - billed_from
 | 
			
		||||
 | 
			
		||||
        # TODO: refactor this thing?
 | 
			
		||||
        # TODO: weekly
 | 
			
		||||
        if self.recurring_period == RecurringPeriod.PER_YEAR:
 | 
			
		||||
            # XXX: Should always be one => we do not bill for more than one year.
 | 
			
		||||
            # TODO: check billed_delta is ~365 days.
 | 
			
		||||
            return 1
 | 
			
		||||
        elif self.recurring_period == RecurringPeriod.PER_MONTH:
 | 
			
		||||
            days = ceil(billed_delta / timedelta(days=1))
 | 
			
		||||
 | 
			
		||||
            # Monthly bills always cover one single month.
 | 
			
		||||
            if (self.bill.starting_date.year != self.bill.starting_date.year or
 | 
			
		||||
                self.bill.starting_date.month != self.bill.ending_date.month):
 | 
			
		||||
                    raise Exception('Bill {} covers more than one month. Cannot bill PER_MONTH.'.
 | 
			
		||||
                        format(self.bill.uuid))
 | 
			
		||||
 | 
			
		||||
            # XXX: minumal length of monthly order is to be enforced somewhere else.
 | 
			
		||||
            (_, days_in_month) = monthrange(
 | 
			
		||||
                    self.bill.starting_date.year,
 | 
			
		||||
                    self.bill.starting_date.month)
 | 
			
		||||
            return days / days_in_month
 | 
			
		||||
        elif self.recurring_period == RecurringPeriod.PER_WEEK:
 | 
			
		||||
            weeks = ceil(billed_delta / timedelta(week=1))
 | 
			
		||||
            return weeks
 | 
			
		||||
        elif self.recurring_period == RecurringPeriod.PER_DAY:
 | 
			
		||||
            days = ceil(billed_delta / timedelta(days=1))
 | 
			
		||||
            return days
 | 
			
		||||
        elif self.recurring_period == RecurringPeriod.PER_HOUR:
 | 
			
		||||
            hours = ceil(billed_delta / timedelta(hours=1))
 | 
			
		||||
            return hours
 | 
			
		||||
        elif self.recurring_period == RecurringPeriod.PER_SECOND:
 | 
			
		||||
            seconds = ceil(billed_delta / timedelta(seconds=1))
 | 
			
		||||
            return seconds
 | 
			
		||||
        elif self.recurring_period == RecurringPeriod.ONE_TIME:
 | 
			
		||||
            return 0
 | 
			
		||||
        else:
 | 
			
		||||
            raise Exception('Unsupported recurring period: {}.'.
 | 
			
		||||
                    format(record.recurring_period))
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def amount(self):
 | 
			
		||||
        return Decimal(float(self.recurring_price) * self.recurring_count) + self.one_time_price
 | 
			
		||||
 | 
			
		||||
###
 | 
			
		||||
# Orders.
 | 
			
		||||
 | 
			
		||||
# Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating
 | 
			
		||||
# bills. Do **NOT** mutate then!
 | 
			
		||||
class Order(models.Model):
 | 
			
		||||
    uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
 | 
			
		||||
    owner = models.ForeignKey(get_user_model(),
 | 
			
		||||
                              on_delete=models.CASCADE,
 | 
			
		||||
                              editable=False)
 | 
			
		||||
 | 
			
		||||
    # TODO: enforce ending_date - starting_date to be larger than recurring_period.
 | 
			
		||||
    creation_date = models.DateTimeField(auto_now_add=True)
 | 
			
		||||
    starting_date = models.DateTimeField()
 | 
			
		||||
    ending_date = models.DateTimeField(blank=True,
 | 
			
		||||
                                       null=True)
 | 
			
		||||
 | 
			
		||||
    bill = models.ManyToManyField(Bill,
 | 
			
		||||
                                  editable=False,
 | 
			
		||||
                                  blank=True)
 | 
			
		||||
 | 
			
		||||
    recurring_period = models.CharField(max_length=32,
 | 
			
		||||
                              choices = RecurringPeriod.choices,
 | 
			
		||||
                              default = RecurringPeriod.PER_MONTH)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def records(self):
 | 
			
		||||
        return OrderRecord.objects.filter(order=self)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def one_time_price(self):
 | 
			
		||||
        return reduce(lambda acc, record: acc + record.one_time_price, self.records, 0)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def recurring_price(self):
 | 
			
		||||
        return reduce(lambda acc, record: acc + record.recurring_price, self.records, 0)
 | 
			
		||||
 | 
			
		||||
    def add_record(self, one_time_price, recurring_price, description):
 | 
			
		||||
        OrderRecord.objects.create(order=self,
 | 
			
		||||
                one_time_price=one_time_price,
 | 
			
		||||
                recurring_price=recurring_price,
 | 
			
		||||
                description=description)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class OrderRecord(models.Model):
 | 
			
		||||
    """
 | 
			
		||||
    Order records store billing informations for products: the actual product
 | 
			
		||||
    might be mutated and/or moved to another order but we do not want to loose
 | 
			
		||||
    the details of old orders.
 | 
			
		||||
 | 
			
		||||
    Used as source of trust to dynamically generate bill entries.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    order = models.ForeignKey(Order, on_delete=models.CASCADE)
 | 
			
		||||
    one_time_price = models.DecimalField(default=0.0,
 | 
			
		||||
            max_digits=AMOUNT_MAX_DIGITS,
 | 
			
		||||
            decimal_places=AMOUNT_DECIMALS,
 | 
			
		||||
            validators=[MinValueValidator(0)])
 | 
			
		||||
    recurring_price = models.DecimalField(default=0.0,
 | 
			
		||||
            max_digits=AMOUNT_MAX_DIGITS,
 | 
			
		||||
            decimal_places=AMOUNT_DECIMALS,
 | 
			
		||||
            validators=[MinValueValidator(0)])
 | 
			
		||||
 | 
			
		||||
    description = models.TextField()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def recurring_period(self):
 | 
			
		||||
        return self.order.recurring_period
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def starting_date(self):
 | 
			
		||||
        return self.order.starting_date
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def ending_date(self):
 | 
			
		||||
        return self.order.ending_date
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
###
 | 
			
		||||
# Products
 | 
			
		||||
 | 
			
		||||
# Abstract (= no database representation) class used as parent for products
 | 
			
		||||
# (e.g.  uncloud_vm.models.VMProduct).
 | 
			
		||||
class Product(UncloudModel):
 | 
			
		||||
    uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
 | 
			
		||||
    owner = models.ForeignKey(get_user_model(),
 | 
			
		||||
                              on_delete=models.CASCADE,
 | 
			
		||||
                              editable=False)
 | 
			
		||||
 | 
			
		||||
    description = ""
 | 
			
		||||
 | 
			
		||||
    status = models.CharField(max_length=32,
 | 
			
		||||
            choices=UncloudStatus.choices,
 | 
			
		||||
            default=UncloudStatus.PENDING)
 | 
			
		||||
 | 
			
		||||
    order = models.ForeignKey(Order,
 | 
			
		||||
                              on_delete=models.CASCADE,
 | 
			
		||||
                              editable=False,
 | 
			
		||||
                              null=True)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH):
 | 
			
		||||
        pass # To be implemented in child.
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def one_time_price(self):
 | 
			
		||||
        return 0
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def recurring_period(self):
 | 
			
		||||
        return self.order.recurring_period
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def allowed_recurring_periods():
 | 
			
		||||
        return RecurringPeriod.choices
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        abstract = True
 | 
			
		||||
							
								
								
									
										71
									
								
								uncloud_django_based/uncloud/uncloud_pay/serializers.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								uncloud_django_based/uncloud/uncloud_pay/serializers.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,71 @@
 | 
			
		|||
from django.contrib.auth import get_user_model
 | 
			
		||||
from rest_framework import serializers
 | 
			
		||||
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):
 | 
			
		||||
    records = OrderRecordSerializer(many=True, read_only=True)
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Order
 | 
			
		||||
        fields = ['uuid', 'creation_date', 'starting_date', 'ending_date',
 | 
			
		||||
                'bill', 'recurring_period', 'records', '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()
 | 
			
		||||
    recurring_period = serializers.CharField()
 | 
			
		||||
    recurring_price = serializers.DecimalField(max_digits=10, decimal_places=2)
 | 
			
		||||
    recurring_count = serializers.DecimalField(max_digits=10, decimal_places=2)
 | 
			
		||||
    one_time_price = serializers.DecimalField(max_digits=10, decimal_places=2)
 | 
			
		||||
    amount = serializers.DecimalField(max_digits=10, decimal_places=2)
 | 
			
		||||
 | 
			
		||||
class BillSerializer(serializers.ModelSerializer):
 | 
			
		||||
    records = BillRecordSerializer(many=True, read_only=True)
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Bill
 | 
			
		||||
        fields = ['reference', 'owner', 'total', 'due_date', 'creation_date',
 | 
			
		||||
                'starting_date', 'ending_date', 'records', 'final']
 | 
			
		||||
							
								
								
									
										114
									
								
								uncloud_django_based/uncloud/uncloud_pay/stripe.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								uncloud_django_based/uncloud/uncloud_pay/stripe.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,114 @@
 | 
			
		|||
import stripe
 | 
			
		||||
import stripe.error
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
from django.core.exceptions import ObjectDoesNotExist
 | 
			
		||||
import uncloud_pay.models
 | 
			
		||||
 | 
			
		||||
import uncloud.secrets
 | 
			
		||||
 | 
			
		||||
# 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 = uncloud.secrets.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 uncloud.secrets.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)
 | 
			
		||||
							
								
								
									
										1128
									
								
								uncloud_django_based/uncloud/uncloud_pay/templates/bill.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1128
									
								
								uncloud_django_based/uncloud/uncloud_pay/templates/bill.html
									
										
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
							
								
								
									
										118
									
								
								uncloud_django_based/uncloud/uncloud_pay/tests.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								uncloud_django_based/uncloud/uncloud_pay/tests.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,118 @@
 | 
			
		|||
from django.test import TestCase
 | 
			
		||||
from django.contrib.auth import get_user_model
 | 
			
		||||
from datetime import datetime, date, timedelta
 | 
			
		||||
 | 
			
		||||
from .models import *
 | 
			
		||||
 | 
			
		||||
class BillingTestCase(TestCase):
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
        self.user = get_user_model().objects.create(
 | 
			
		||||
                username='jdoe',
 | 
			
		||||
                email='john.doe@domain.tld')
 | 
			
		||||
 | 
			
		||||
    def test_truth(self):
 | 
			
		||||
        self.assertEqual(1+1, 2)
 | 
			
		||||
 | 
			
		||||
    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)
 | 
			
		||||
        order.add_record(one_time_price, recurring_price, description)
 | 
			
		||||
 | 
			
		||||
        # Generate & check bill for first month: full recurring_price + setup.
 | 
			
		||||
        first_month_bills = Bill.generate_for(2020, 3, self.user)
 | 
			
		||||
        self.assertEqual(len(first_month_bills), 1)
 | 
			
		||||
        self.assertEqual(first_month_bills[0].total, 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].total, 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].total),
 | 
			
		||||
                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)
 | 
			
		||||
        order.add_record(one_time_price, recurring_price, description)
 | 
			
		||||
 | 
			
		||||
        # Generate & check bill for first year: recurring_price + setup.
 | 
			
		||||
        first_year_bills = Bill.generate_for(2020, 3, self.user)
 | 
			
		||||
        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].total,
 | 
			
		||||
                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].total, 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)
 | 
			
		||||
        order.add_record(one_time_price, recurring_price, description)
 | 
			
		||||
 | 
			
		||||
        # Generate & check bill for first month: recurring_price + setup.
 | 
			
		||||
        first_month_bills = Bill.generate_for(2020, 3, self.user)
 | 
			
		||||
        self.assertEqual(len(first_month_bills), 1)
 | 
			
		||||
        self.assertEqual(float(first_month_bills[0].total),
 | 
			
		||||
                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].total),
 | 
			
		||||
                round(12 * recurring_price, AMOUNT_DECIMALS))
 | 
			
		||||
							
								
								
									
										241
									
								
								uncloud_django_based/uncloud/uncloud_pay/views.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										241
									
								
								uncloud_django_based/uncloud/uncloud_pay/views.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,241 @@
 | 
			
		|||
from django.shortcuts import render
 | 
			
		||||
from django.db import transaction
 | 
			
		||||
from django.contrib.auth import get_user_model
 | 
			
		||||
from rest_framework import viewsets, 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
 | 
			
		||||
 | 
			
		||||
import json
 | 
			
		||||
 | 
			
		||||
from .models import *
 | 
			
		||||
from .serializers import *
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
import uncloud_pay.stripe as uncloud_stripe
 | 
			
		||||
 | 
			
		||||
###
 | 
			
		||||
# 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)
 | 
			
		||||
 | 
			
		||||
    def unpaid(self, request):
 | 
			
		||||
        return Bill.objects.filter(owner=self.request.user, paid=False)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class OrderViewSet(viewsets.ReadOnlyModelViewSet):
 | 
			
		||||
    serializer_class = OrderSerializer
 | 
			
		||||
    permission_classes = [permissions.IsAuthenticated]
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return Order.objects.filter(owner=self.request.user)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
###
 | 
			
		||||
# Old admin stuff.
 | 
			
		||||
 | 
			
		||||
class AdminPaymentViewSet(viewsets.ModelViewSet):
 | 
			
		||||
    serializer_class = PaymentSerializer
 | 
			
		||||
    permission_classes = [permissions.IsAuthenticated]
 | 
			
		||||
 | 
			
		||||
    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)
 | 
			
		||||
 | 
			
		||||
class AdminBillViewSet(viewsets.ModelViewSet):
 | 
			
		||||
    serializer_class = BillSerializer
 | 
			
		||||
    permission_classes = [permissions.IsAuthenticated]
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return Bill.objects.all()
 | 
			
		||||
 | 
			
		||||
    def unpaid(self, request):
 | 
			
		||||
        return Bill.objects.filter(owner=self.request.user, paid=False)
 | 
			
		||||
 | 
			
		||||
    def create(self, request):
 | 
			
		||||
        serializer = self.get_serializer(data=request.data)
 | 
			
		||||
        serializer.is_valid(raise_exception=True)
 | 
			
		||||
        serializer.save(creation_date=datetime.now())
 | 
			
		||||
 | 
			
		||||
        headers = self.get_success_headers(serializer.data)
 | 
			
		||||
        return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
 | 
			
		||||
 | 
			
		||||
class AdminOrderViewSet(viewsets.ModelViewSet):
 | 
			
		||||
    serializer_class = OrderSerializer
 | 
			
		||||
    permission_classes = [permissions.IsAuthenticated]
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return Order.objects.all()
 | 
			
		||||
 | 
			
		||||
# PDF tests
 | 
			
		||||
from django.views.generic import TemplateView
 | 
			
		||||
from hardcopy.views import PDFViewMixin, PNGViewMixin
 | 
			
		||||
 | 
			
		||||
class MyPDFView(PDFViewMixin, TemplateView):
 | 
			
		||||
    template_name = "bill.html"
 | 
			
		||||
    # def get_filename(self):
 | 
			
		||||
    #     return "my_file_{}.pdf".format(now().strftime('Y-m-d'))
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue