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 import itertools 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) # http://xml.coverpages.org/country3166.html COUNTRIES = ( ('AD', _('Andorra')), ('AE', _('United Arab Emirates')), ('AF', _('Afghanistan')), ('AG', _('Antigua & Barbuda')), ('AI', _('Anguilla')), ('AL', _('Albania')), ('AM', _('Armenia')), ('AN', _('Netherlands Antilles')), ('AO', _('Angola')), ('AQ', _('Antarctica')), ('AR', _('Argentina')), ('AS', _('American Samoa')), ('AT', _('Austria')), ('AU', _('Australia')), ('AW', _('Aruba')), ('AZ', _('Azerbaijan')), ('BA', _('Bosnia and Herzegovina')), ('BB', _('Barbados')), ('BD', _('Bangladesh')), ('BE', _('Belgium')), ('BF', _('Burkina Faso')), ('BG', _('Bulgaria')), ('BH', _('Bahrain')), ('BI', _('Burundi')), ('BJ', _('Benin')), ('BM', _('Bermuda')), ('BN', _('Brunei Darussalam')), ('BO', _('Bolivia')), ('BR', _('Brazil')), ('BS', _('Bahama')), ('BT', _('Bhutan')), ('BV', _('Bouvet Island')), ('BW', _('Botswana')), ('BY', _('Belarus')), ('BZ', _('Belize')), ('CA', _('Canada')), ('CC', _('Cocos (Keeling) Islands')), ('CF', _('Central African Republic')), ('CG', _('Congo')), ('CH', _('Switzerland')), ('CI', _('Ivory Coast')), ('CK', _('Cook Iislands')), ('CL', _('Chile')), ('CM', _('Cameroon')), ('CN', _('China')), ('CO', _('Colombia')), ('CR', _('Costa Rica')), ('CU', _('Cuba')), ('CV', _('Cape Verde')), ('CX', _('Christmas Island')), ('CY', _('Cyprus')), ('CZ', _('Czech Republic')), ('DE', _('Germany')), ('DJ', _('Djibouti')), ('DK', _('Denmark')), ('DM', _('Dominica')), ('DO', _('Dominican Republic')), ('DZ', _('Algeria')), ('EC', _('Ecuador')), ('EE', _('Estonia')), ('EG', _('Egypt')), ('EH', _('Western Sahara')), ('ER', _('Eritrea')), ('ES', _('Spain')), ('ET', _('Ethiopia')), ('FI', _('Finland')), ('FJ', _('Fiji')), ('FK', _('Falkland Islands (Malvinas)')), ('FM', _('Micronesia')), ('FO', _('Faroe Islands')), ('FR', _('France')), ('FX', _('France, Metropolitan')), ('GA', _('Gabon')), ('GB', _('United Kingdom (Great Britain)')), ('GD', _('Grenada')), ('GE', _('Georgia')), ('GF', _('French Guiana')), ('GH', _('Ghana')), ('GI', _('Gibraltar')), ('GL', _('Greenland')), ('GM', _('Gambia')), ('GN', _('Guinea')), ('GP', _('Guadeloupe')), ('GQ', _('Equatorial Guinea')), ('GR', _('Greece')), ('GS', _('South Georgia and the South Sandwich Islands')), ('GT', _('Guatemala')), ('GU', _('Guam')), ('GW', _('Guinea-Bissau')), ('GY', _('Guyana')), ('HK', _('Hong Kong')), ('HM', _('Heard & McDonald Islands')), ('HN', _('Honduras')), ('HR', _('Croatia')), ('HT', _('Haiti')), ('HU', _('Hungary')), ('ID', _('Indonesia')), ('IE', _('Ireland')), ('IL', _('Israel')), ('IN', _('India')), ('IO', _('British Indian Ocean Territory')), ('IQ', _('Iraq')), ('IR', _('Islamic Republic of Iran')), ('IS', _('Iceland')), ('IT', _('Italy')), ('JM', _('Jamaica')), ('JO', _('Jordan')), ('JP', _('Japan')), ('KE', _('Kenya')), ('KG', _('Kyrgyzstan')), ('KH', _('Cambodia')), ('KI', _('Kiribati')), ('KM', _('Comoros')), ('KN', _('St. Kitts and Nevis')), ('KP', _('Korea, Democratic People\'s Republic of')), ('KR', _('Korea, Republic of')), ('KW', _('Kuwait')), ('KY', _('Cayman Islands')), ('KZ', _('Kazakhstan')), ('LA', _('Lao People\'s Democratic Republic')), ('LB', _('Lebanon')), ('LC', _('Saint Lucia')), ('LI', _('Liechtenstein')), ('LK', _('Sri Lanka')), ('LR', _('Liberia')), ('LS', _('Lesotho')), ('LT', _('Lithuania')), ('LU', _('Luxembourg')), ('LV', _('Latvia')), ('LY', _('Libyan Arab Jamahiriya')), ('MA', _('Morocco')), ('MC', _('Monaco')), ('MD', _('Moldova, Republic of')), ('MG', _('Madagascar')), ('MH', _('Marshall Islands')), ('ML', _('Mali')), ('MN', _('Mongolia')), ('MM', _('Myanmar')), ('MO', _('Macau')), ('MP', _('Northern Mariana Islands')), ('MQ', _('Martinique')), ('MR', _('Mauritania')), ('MS', _('Monserrat')), ('MT', _('Malta')), ('MU', _('Mauritius')), ('MV', _('Maldives')), ('MW', _('Malawi')), ('MX', _('Mexico')), ('MY', _('Malaysia')), ('MZ', _('Mozambique')), ('NA', _('Namibia')), ('NC', _('New Caledonia')), ('NE', _('Niger')), ('NF', _('Norfolk Island')), ('NG', _('Nigeria')), ('NI', _('Nicaragua')), ('NL', _('Netherlands')), ('NO', _('Norway')), ('NP', _('Nepal')), ('NR', _('Nauru')), ('NU', _('Niue')), ('NZ', _('New Zealand')), ('OM', _('Oman')), ('PA', _('Panama')), ('PE', _('Peru')), ('PF', _('French Polynesia')), ('PG', _('Papua New Guinea')), ('PH', _('Philippines')), ('PK', _('Pakistan')), ('PL', _('Poland')), ('PM', _('St. Pierre & Miquelon')), ('PN', _('Pitcairn')), ('PR', _('Puerto Rico')), ('PT', _('Portugal')), ('PW', _('Palau')), ('PY', _('Paraguay')), ('QA', _('Qatar')), ('RE', _('Reunion')), ('RO', _('Romania')), ('RU', _('Russian Federation')), ('RW', _('Rwanda')), ('SA', _('Saudi Arabia')), ('SB', _('Solomon Islands')), ('SC', _('Seychelles')), ('SD', _('Sudan')), ('SE', _('Sweden')), ('SG', _('Singapore')), ('SH', _('St. Helena')), ('SI', _('Slovenia')), ('SJ', _('Svalbard & Jan Mayen Islands')), ('SK', _('Slovakia')), ('SL', _('Sierra Leone')), ('SM', _('San Marino')), ('SN', _('Senegal')), ('SO', _('Somalia')), ('SR', _('Suriname')), ('ST', _('Sao Tome & Principe')), ('SV', _('El Salvador')), ('SY', _('Syrian Arab Republic')), ('SZ', _('Swaziland')), ('TC', _('Turks & Caicos Islands')), ('TD', _('Chad')), ('TF', _('French Southern Territories')), ('TG', _('Togo')), ('TH', _('Thailand')), ('TJ', _('Tajikistan')), ('TK', _('Tokelau')), ('TM', _('Turkmenistan')), ('TN', _('Tunisia')), ('TO', _('Tonga')), ('TP', _('East Timor')), ('TR', _('Turkey')), ('TT', _('Trinidad & Tobago')), ('TV', _('Tuvalu')), ('TW', _('Taiwan, Province of China')), ('TZ', _('Tanzania, United Republic of')), ('UA', _('Ukraine')), ('UG', _('Uganda')), ('UM', _('United States Minor Outlying Islands')), ('US', _('United States of America')), ('UY', _('Uruguay')), ('UZ', _('Uzbekistan')), ('VA', _('Vatican City State (Holy See)')), ('VC', _('St. Vincent & the Grenadines')), ('VE', _('Venezuela')), ('VG', _('British Virgin Islands')), ('VI', _('United States Virgin Islands')), ('VN', _('Viet Nam')), ('VU', _('Vanuatu')), ('WF', _('Wallis & Futuna Islands')), ('WS', _('Samoa')), ('YE', _('Yemen')), ('YT', _('Mayotte')), ('YU', _('Yugoslavia')), ('ZA', _('South Africa')), ('ZM', _('Zambia')), ('ZR', _('Zaire')), ('ZW', _('Zimbabwe')), ) # 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_WEEK = 'WEEK', _('Per Week') PER_DAY = 'DAY', _('Per Day') PER_HOUR = 'HOUR', _('Per Hour') PER_MINUTE = 'MINUTE', _('Per Minute') PER_SECOND = 'SECOND', _('Per Second') class CountryField(models.CharField): def __init__(self, *args, **kwargs): kwargs.setdefault('choices', COUNTRIES) kwargs.setdefault('default', 'CH') kwargs.setdefault('max_length', 2) super(CountryField, self).__init__(*args, **kwargs) def get_internal_type(self): return "CharField" 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) # We override save() in order to active products awaiting payment. def save(self, *args, **kwargs): # _state.adding is switched to false after super(...) call. being_created = self._state.adding 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_products() 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 BillingAddress(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) name = models.CharField(max_length=100) street = models.CharField(max_length=100) city = models.CharField(max_length=50) postal_code = models.CharField(max_length=50) country = CountryField(blank=True) vat_number = models.CharField(max_length=100, default="", blank=True) @staticmethod def get_addresses_for(user): return BillingAddress.objects.filter(owner=user) @staticmethod def get_preferred_address_for(user): addresses = get_addresses_for(user) if len(addresses) == 0: return None else: # TODO: allow user to set primary/preferred address return addresses[0] def __str__(self): return "{}, {}, {} {}, {}".format( self.name, self.street, self.postal_code, self.city, self.country) # Populated with the import-vat-numbers django command. class VATRate(models.Model): start_date = models.DateField(blank=True, null=True) stop_date = models.DateField(blank=True, null=True) territory_codes = models.TextField(blank=True, default='') currency_code = models.CharField(max_length=10) rate = models.FloatField() rate_type = models.TextField(blank=True, default='') description = models.TextField(blank=True, default='') @staticmethod def get_for_country(country_code): vat_rate = None try: vat_rate = VATRate.objects.get( territory_codes=country_code, start_date__isnull=False, stop_date=None ) return vat_rate.rate except VATRate.DoesNotExist as dne: logger.debug(str(dne)) logger.debug("Did not find VAT rate for %s, returning 0" % country_code) return 0 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) # Trigger product activation if bill paid at creation (from balance). def save(self, *args, **kwargs): super(Bill, self).save(*args, **kwargs) if not self in Bill.get_unpaid_for(self.owner): self.activate_products() @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 amount(self): return reduce(lambda acc, record: acc + record.amount, self.records, 0) @property def vat_amount(self): return reduce(lambda acc, record: acc + record.vat_amount, self.records, 0) @property def total(self): return self.amount + self.vat_amount @property def final(self): # A bill is final when its ending date is passed. return self.ending_date < timezone.now() def activate_products(self): for order in self.order_set.all(): # FIXME: using __something might not be a good idea. for product_class in Product.__subclasses__(): for product in product_class.objects.filter(order=order): if product.status == UncloudStatus.AWAITING_PAYMENT: product.status = UncloudStatus.PENDING product.save() @property def billing_address(self): orders = Order.objects.filter(bill=self) # The genrate_for method makes sure all the orders of a bill share the # same billing address. TODO: It would be nice to enforce that somehow... return orders[0].billing_address # TODO: split this huuuge method! @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 # There should not be any bill linked to orders with different # billing addresses. per_address_orders = itertools.groupby( unpaid_orders['monthly_or_less'], lambda o: o.billing_address) for addr, bill_orders in per_address_orders: 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 bill_orders: 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) # There should not be any bill linked to orders with different # billing addresses. per_address_orders = itertools.groupby( unpaid_orders['yearly'][next_yearly_bill_start_on], lambda o: o.billing_address) for addr, bill_orders in per_address_orders: 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 bill_orders: 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(user) unpaid_bills = [] # No unpaid bill if balance is positive. if balance >= 0: return unpaid_bills else: bills = Bill.objects.filter( owner=user, ).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.total 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 vat_rate(self): return Decimal(VATRate.get_for_country(self.bill.billing_address.country)) @property def vat_amount(self): return self.amount * self.vat_rate @property def amount(self): return Decimal(float(self.recurring_price) * self.recurring_count) + self.one_time_price @property def total(self): return self.amount + self.vat_amount ### # 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) billing_address = models.ForeignKey(BillingAddress, on_delete=models.CASCADE) # 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) # Trigger initial bill generation at order creation. def save(self, *args, **kwargs): super(Order, self).save(*args, **kwargs) Bill.generate_for(self.starting_date.year, self.starting_date.month, self.owner) @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) # Used by uncloud_pay tests. @property def bills(self): return Bill.objects.filter(order=self) 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 = "Generic Product" status = models.CharField(max_length=32, choices=UncloudStatus.choices, default=UncloudStatus.AWAITING_PAYMENT) order = models.ForeignKey(Order, on_delete=models.CASCADE, editable=False, null=True) # Default period for all products default_recurring_period = RecurringPeriod.PER_MONTH # Used to save records. def save(self, *args, **kwargs): # _state.adding is switched to false after super(...) call. being_created = self._state.adding super(Product, self).save(*args, **kwargs) # Make sure we only create records on creation. if being_created: record = OrderRecord( one_time_price=self.one_time_price, recurring_price=self.recurring_price, description=self.description) self.order.orderrecord_set.add(record, bulk=False) @property def recurring_price(self): pass # To be implemented in child. @property def one_time_price(self): return 0 @property def recurring_period(self): return self.order.recurring_period @property def billing_address(self): return self.order.billing_address @staticmethod def allowed_recurring_periods(): return RecurringPeriod.choices class Meta: abstract = True def discounted_price_by_period(self, requested_period): """ Each product has a standard recurring period for which we define a pricing. I.e. VPN is usually year, VM is usually monthly. The user can opt-in to use a different period, which influences the price: The longer a user commits, the higher the discount. Products can also be limited in the available periods. For instance a VPN only makes sense to be bought for at least one day. Rules are as follows: given a standard recurring period of ..., changing to ... modifies price ... # One month for free if buying / year, compared to a month: about 8.33% discount per_year -> per_month -> /11 per_month -> per_year -> *11 # Month has 30.42 days on average. About 7.9% discount to go monthly per_month -> per_day -> /28 per_day -> per_month -> *28 # Day has 24h, give one for free per_day -> per_hour -> /23 per_hour -> per_day -> /23 Examples VPN @ 120CHF/y becomes - 10.91 CHF/month (130.91 CHF/year) - 0.39 CHF/day (142.21 CHF/year) VM @ 15 CHF/month becomes - 165 CHF/month (13.75 CHF/month) - 0.54 CHF/day (16.30 CHF/month) """ if self.default_recurring_period == RecurringPeriod.PER_YEAR: if requested_period == RecurringPeriod.PER_YEAR: return self.recurring_price if requested_period == RecurringPeriod.PER_MONTH: return self.recurring_price/11. if requested_period == RecurringPeriod.PER_DAY: return self.recurring_price/11./28. elif self.default_recurring_period == RecurringPeriod.PER_MONTH: if requested_period == RecurringPeriod.PER_YEAR: return self.recurring_price*11 if requested_period == RecurringPeriod.PER_MONTH: return self.recurring_price if requested_period == RecurringPeriod.PER_DAY: return self.recurring_price/28. elif self.default_recurring_period == RecurringPeriod.PER_DAY: if requested_period == RecurringPeriod.PER_YEAR: return self.recurring_price*11*28 if requested_period == RecurringPeriod.PER_MONTH: return self.recurring_price*28 if requested_period == RecurringPeriod.PER_DAY: return self.recurring_price else: # FIXME: use the right type of exception here! raise Exception("Did not implement the discounter for this case")