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.dispatch import receiver from django.core.exceptions import ObjectDoesNotExist import django.db.models.signals as signals import uuid from functools import reduce from math import ceil from datetime import timedelta from calendar import monthrange import uncloud_pay.stripe from uncloud_pay.helpers import beginning_of_month, end_of_month from decimal import Decimal # Define DecimalField properties, used to represent amounts of money. AMOUNT_MAX_DIGITS=10 AMOUNT_DECIMALS=2 # Used to generate bill due dates. BILL_PAYMENT_DELAY=timedelta(days=10) # 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') # See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types class ProductStatus(models.TextChoices): PENDING = 'PENDING', _('Pending') AWAITING_PAYMENT = 'AWAITING_PAYMENT', _('Awaiting payment') BEING_CREATED = 'BEING_CREATED', _('Being created') ACTIVE = 'ACTIVE', _('Active') DELETED = 'DELETED', _('Deleted') ### # Users. def get_balance_for(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=True) # 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) print(stripe_payment) 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 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. # Default values for next bill (if any). Only saved at the end of # this method, if relevant. next_bill = Bill(owner=user, starting_date=beginning_of_month(year, month), ending_date=end_of_month(year, month), creation_date=timezone.now(), due_date=timezone.now() + BILL_PAYMENT_DELAY) # Select all orders active on the request period. orders = Order.objects.filter( Q(ending_date__gt=next_bill.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 # * If previous_bill.ending_date is before next_bill.ending_date, a new # bill has to be generated. unpaid_orders = [] for order in orders: try: previous_bill = order.bill.latest('ending_date') except ObjectDoesNotExist: previous_bill = None if previous_bill == None or previous_bill.ending_date < next_bill.ending_date: unpaid_orders.append(order) # Commit next_bill if it there are 'unpaid' orders. if len(unpaid_orders) > 0: next_bill.save() # It is not possible to register many-to-many relationship before # the two end-objects are saved in database. for order in unpaid_orders: order.bill.add(next_bill) # TODO: use logger. print("Generated bill {} (amount: {}) for user {}." .format(next_bill.uuid, next_bill.total, user)) return next_bill # Return None if no bill was created. return None @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.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=32, choices=ProductStatus.choices, default=ProductStatus.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