from django.db import models from functools import reduce 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 math import ceil from datetime import timedelta from calendar import monthrange import uncloud_pay.stripe from decimal import Decimal import uuid # Define DecimalField properties, used to represent amounts of money. AMOUNT_MAX_DIGITS=10 AMOUNT_DECIMALS=2 # 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_DAY = 'DAY', _('Per Day') PER_HOUR = 'HOUR', _('Per Hour') PER_SECOND = 'SECOND', _('Per Second') ### # 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) 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=True) # Only used for "Stripe" source stripe_card_id = models.CharField(max_length=32, blank=True, null=True) @property def stripe_card_last4(self): if self.source == 'stripe': card_request = uncloud_pay.stripe.get_card( StripeCustomer.objects.get(owner=self.owner).stripe_id, self.stripe_card_id) if card_request['error'] == None: return card_request['response_object']['last4'] else: return None else: return None def charge(self, amount): if amount > 0: # Make sure we don't charge negative amount by errors... if self.source == 'stripe': stripe_customer = StripeCustomer.objects.get(owner=self.owner).stripe_id charge_request = uncloud_pay.stripe.charge_customer(amount, stripe_customer, self.stripe_card_id) if charge_request['error'] == None: payment = Payment(owner=self.owner, source=self.source, amount=amount) payment.save() # TODO: Check return status return payment else: raise Exception('Stripe error: {}'.format(charge_request['error'])) else: raise Exception('This payment method is unsupported/cannot be charged.') else: raise Exception('Cannot charge negative amount.') class Meta: unique_together = [['owner', 'primary']] class StripeCustomer(models.Model): owner = models.OneToOneField( get_user_model(), primary_key=True, on_delete=models.CASCADE) stripe_id = models.CharField(max_length=32) ### # Bills & Payments. 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 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() 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.order.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 edges cases. This should not be # possible. raise Exception('Impossible billing delta!') billed_delta = billed_until - billed_from # TODO: refactor this thing? # TODO: weekly # TODO: yearly if self.recurring_period == RecurringPeriod.PER_MONTH: days = ceil(billed_delta / timedelta(days=1)) # XXX: we assume monthly bills for now. 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 Decimal(days / days_in_month) elif self.recurring_period == RecurringPeriod.PER_DAY: days = ceil(billed_delta / timedelta(days=1)) return Decimal(days) elif self.recurring_period == RecurringPeriod.PER_HOUR: hours = ceil(billed_delta / timedelta(hours=1)) return Decimal(hours) elif self.recurring_period == RecurringPeriod.PER_SECOND: seconds = ceil(billed_delta / timedelta(seconds=1)) return Decimal(seconds) elif self.recurring_period == RecurringPeriod.ONE_TIME: return Decimal(0) else: raise Exception('Unsupported recurring period: {}.'. format(record.recurring_period)) @property def amount(self): return 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(auto_now_add=True) 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(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) description = "" status = models.CharField(max_length=256, choices = ( ('pending', 'Pending'), ('being_created', 'Being created'), ('active', 'Active'), ('deleted', 'Deleted') ), default='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