Initial yearly billing implementation

This commit is contained in:
fnux 2020-03-05 16:22:41 +01:00
parent c086dbd357
commit d089d06264
2 changed files with 94 additions and 29 deletions

View file

@ -219,51 +219,106 @@ class Bill(models.Model):
@staticmethod @staticmethod
def generate_for(year, month, user): def generate_for(year, month, user):
# /!\ We exclusively work on the specified year and month. # /!\ We exclusively work on the specified year and month.
generated_bills = []
# Default values for next bill (if any). Only saved at the end of # Default values for next bill (if any).
# this method, if relevant. starting_date=beginning_of_month(year, month)
next_bill = Bill(owner=user, ending_date=end_of_month(year, month)
starting_date=beginning_of_month(year, month), creation_date=timezone.now()
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. # Select all orders active on the request period (i.e. starting on or after starting_date).
orders = Order.objects.filter( orders = Order.objects.filter(
Q(ending_date__gt=next_bill.starting_date) | Q(ending_date__isnull=True), Q(ending_date__gte=starting_date) | Q(ending_date__isnull=True),
owner=user) owner=user)
# Check if there is already a bill covering the order and period pair: # Check if there is already a bill covering the order and period pair:
# * Get latest bill by ending_date: previous_bill.ending_date # * Get latest bill by ending_date: previous_bill.ending_date
# * If previous_bill.ending_date is before next_bill.ending_date, a new # * For monthly bills: if previous_bill.ending_date is before
# bill has to be generated. # (next_bill) ending_date, a new bill has to be generated.
unpaid_orders = [] # * 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: for order in orders:
try: try:
previous_bill = order.bill.latest('ending_date') previous_bill = order.bill.latest('ending_date')
except ObjectDoesNotExist: except ObjectDoesNotExist:
previous_bill = None previous_bill = None
if previous_bill == None or previous_bill.ending_date < next_bill.ending_date: # FIXME: control flow is confusing in this block.
unpaid_orders.append(order) 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 + timedelta(days=1)).date()
elif previous_bill.ending_date <= ending_date:
next_yearly_bill_start_on = (previous_bill.ending_date + timedelta(days=1)).date()
# Commit next_bill if it there are 'unpaid' orders. # Store for bill generation. One bucket per day of month with a starting bill.
if len(unpaid_orders) > 0: # bucket is a reference here, no need to reassign.
next_bill.save() if next_yearly_bill_start_on:
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.datetime(), # 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 # It is not possible to register many-to-many relationship before
# the two end-objects are saved in database. # the two end-objects are saved in database.
for order in unpaid_orders: for order in unpaid_orders['monthly_or_less']:
order.bill.add(next_bill) order.bill.add(next_monthly_bill)
# TODO: use logger. # TODO: use logger.
print("Generated bill {} (amount: {}) for user {}." print("Generated monthly bill {} (amount: {}) for user {}."
.format(next_bill.uuid, next_bill.total, user)) .format(next_monthly_bill.uuid, next_monthly_bill.total, user))
return next_bill # Add to output.
generated_bills.append(next_monthly_bill)
# Return None if no bill was created. # Handle yearly bills starting on working month.
return None 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.date(), next_yearly_bill_start_on) + BILL_PAYMENT_DELAY
# FIXME: a year is not exactly 365 days...
ending_date = next_yearly_bill_start_on + timedelta(days=365)
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)
# TODO: use logger.
print("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 @staticmethod
def get_unpaid_for(user): def get_unpaid_for(user):
@ -323,7 +378,7 @@ class BillRecord():
billed_from = self.order.starting_date billed_from = self.order.starting_date
if billed_from > billed_until: if billed_from > billed_until:
# TODO: think about and check edges cases. This should not be # TODO: think about and check edge cases. This should not be
# possible. # possible.
raise Exception('Impossible billing delta!') raise Exception('Impossible billing delta!')
@ -331,11 +386,15 @@ class BillRecord():
# TODO: refactor this thing? # TODO: refactor this thing?
# TODO: weekly # TODO: weekly
# TODO: yearly if self.recurring_period == RecurringPeriod.PER_YEAR:
if self.recurring_period == RecurringPeriod.PER_MONTH: # Should always be one, but let's be careful.
# FIXME: a year is not exactly 365 days...
years = (billed_delta / timedelta(days=365))
return Decimal(years)
elif self.recurring_period == RecurringPeriod.PER_MONTH:
days = ceil(billed_delta / timedelta(days=1)) days = ceil(billed_delta / timedelta(days=1))
# XXX: we assume monthly bills for now. # Monthly bills always cover one single month.
if (self.bill.starting_date.year != self.bill.starting_date.year or if (self.bill.starting_date.year != self.bill.starting_date.year or
self.bill.starting_date.month != self.bill.ending_date.month): self.bill.starting_date.month != self.bill.ending_date.month):
raise Exception('Bill {} covers more than one month. Cannot bill PER_MONTH.'. raise Exception('Bill {} covers more than one month. Cannot bill PER_MONTH.'.
@ -346,6 +405,9 @@ class BillRecord():
self.bill.starting_date.year, self.bill.starting_date.year,
self.bill.starting_date.month) self.bill.starting_date.month)
return Decimal(days / days_in_month) return Decimal(days / days_in_month)
elif self.recurring_period == RecurringPeriod.PER_WEEK:
weeks = ceil(billed_delta / timedelta(week=1))
return Decimal(weeks)
elif self.recurring_period == RecurringPeriod.PER_DAY: elif self.recurring_period == RecurringPeriod.PER_DAY:
days = ceil(billed_delta / timedelta(days=1)) days = ceil(billed_delta / timedelta(days=1))
return Decimal(days) return Decimal(days)

View file

@ -72,6 +72,8 @@ class VMProduct(Product):
return self.cores * 3 + self.ram_in_gb * 4 return self.cores * 3 + self.ram_in_gb * 4
elif recurring_period == RecurringPeriod.PER_HOUR: elif recurring_period == RecurringPeriod.PER_HOUR:
return self.cores * 4.0/(30 * 24) + self.ram_in_gb * 4.5/(30* 24) return self.cores * 4.0/(30 * 24) + self.ram_in_gb * 4.5/(30* 24)
elif recurring_period == RecurringPeriod.PER_YEAR:
return (self.cores * 2.5 + self.ram_in_gb * 3.5) * 12
else: else:
raise Exception('Invalid recurring period for VM Product pricing.') raise Exception('Invalid recurring period for VM Product pricing.')
@ -88,7 +90,8 @@ class VMProduct(Product):
@staticmethod @staticmethod
def allowed_recurring_periods(): def allowed_recurring_periods():
return list(filter( return list(filter(
lambda pair: pair[0] in [RecurringPeriod.PER_MONTH, RecurringPeriod.PER_HOUR], lambda pair: pair[0] in [RecurringPeriod.PER_YEAR,
RecurringPeriod.PER_MONTH, RecurringPeriod.PER_HOUR],
RecurringPeriod.choices)) RecurringPeriod.choices))
class VMWithOSProduct(VMProduct): class VMWithOSProduct(VMProduct):