Fix various billing issues discovered by testing

This commit is contained in:
fnux 2020-03-09 11:30:31 +01:00
parent fe0e6d98bf
commit 623d3ae5c4
1 changed files with 34 additions and 24 deletions

View File

@ -9,6 +9,7 @@ from django.core.exceptions import ObjectDoesNotExist
import django.db.models.signals as signals import django.db.models.signals as signals
import uuid import uuid
import logging
from functools import reduce from functools import reduce
from math import ceil from math import ceil
from datetime import timedelta from datetime import timedelta
@ -18,20 +19,28 @@ import uncloud_pay.stripe
from uncloud_pay.helpers import beginning_of_month, end_of_month from uncloud_pay.helpers import beginning_of_month, end_of_month
from decimal import Decimal from decimal import Decimal
import decimal
# Define DecimalField properties, used to represent amounts of money. # Define DecimalField properties, used to represent amounts of money.
AMOUNT_MAX_DIGITS=10 AMOUNT_MAX_DIGITS=10
AMOUNT_DECIMALS=2 AMOUNT_DECIMALS=2
# FIXME: check why we need +1 here.
decimal.getcontext().prec = AMOUNT_DECIMALS + 1
# Used to generate bill due dates. # Used to generate bill due dates.
BILL_PAYMENT_DELAY=timedelta(days=10) 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 # See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types
class RecurringPeriod(models.TextChoices): class RecurringPeriod(models.TextChoices):
ONE_TIME = 'ONCE', _('Onetime') ONE_TIME = 'ONCE', _('Onetime')
PER_YEAR = 'YEAR', _('Per Year') PER_YEAR = 'YEAR', _('Per Year')
PER_MONTH = 'MONTH', _('Per Month') PER_MONTH = 'MONTH', _('Per Month')
PER_MINUTE = 'MINUTE', _('Per Minute') PER_MINUTE = 'MINUTE', _('Per Minute')
PER_WEEK = 'WEEK', _('Per Week')
PER_DAY = 'DAY', _('Per Day') PER_DAY = 'DAY', _('Per Day')
PER_HOUR = 'HOUR', _('Per Hour') PER_HOUR = 'HOUR', _('Per Hour')
PER_SECOND = 'SECOND', _('Per Second') PER_SECOND = 'SECOND', _('Per Second')
@ -249,13 +258,16 @@ class Bill(models.Model):
# We ignore anything smaller than a day in here. # We ignore anything smaller than a day in here.
next_yearly_bill_start_on = None next_yearly_bill_start_on = None
if previous_bill == None: if previous_bill == None:
next_yearly_bill_start_on = (order.starting_date + timedelta(days=1)).date() next_yearly_bill_start_on = order.starting_date
elif previous_bill.ending_date <= ending_date: elif previous_bill.ending_date <= ending_date:
next_yearly_bill_start_on = (previous_bill.ending_date + timedelta(days=1)).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. # Store for bill generation. One bucket per day of month with a starting bill.
# bucket is a reference here, no need to reassign. # bucket is a reference here, no need to reassign.
if next_yearly_bill_start_on: 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) bucket = unpaid_orders['yearly'].get(next_yearly_bill_start_on)
if bucket == None: if bucket == None:
unpaid_orders['yearly'][next_yearly_bill_start_on] = [order] unpaid_orders['yearly'][next_yearly_bill_start_on] = [order]
@ -273,7 +285,7 @@ class Bill(models.Model):
next_monthly_bill = Bill.objects.create(owner=user, next_monthly_bill = Bill.objects.create(owner=user,
creation_date=creation_date, creation_date=creation_date,
starting_date=starting_date.datetime(), # FIXME: this is a hack! starting_date=starting_date, # FIXME: this is a hack!
ending_date=ending_date, ending_date=ending_date,
due_date=postpaid_due_date) due_date=postpaid_due_date)
@ -282,8 +294,7 @@ class Bill(models.Model):
for order in unpaid_orders['monthly_or_less']: for order in unpaid_orders['monthly_or_less']:
order.bill.add(next_monthly_bill) order.bill.add(next_monthly_bill)
# TODO: use logger. logger.info("Generated monthly bill {} (amount: {}) for user {}."
print("Generated monthly bill {} (amount: {}) for user {}."
.format(next_monthly_bill.uuid, next_monthly_bill.total, user)) .format(next_monthly_bill.uuid, next_monthly_bill.total, user))
# Add to output. # Add to output.
@ -295,9 +306,10 @@ class Bill(models.Model):
# For every starting date, generate new bill. # For every starting date, generate new bill.
for next_yearly_bill_start_on in unpaid_orders['yearly']: for next_yearly_bill_start_on in unpaid_orders['yearly']:
# No postpaid for yearly payments. # No postpaid for yearly payments.
prepaid_due_date = min(creation_date.date(), next_yearly_bill_start_on) + BILL_PAYMENT_DELAY prepaid_due_date = min(creation_date, next_yearly_bill_start_on) + BILL_PAYMENT_DELAY
# FIXME: a year is not exactly 365 days... # Bump by one year, remove one day.
ending_date = next_yearly_bill_start_on + timedelta(days=365) 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, next_yearly_bill = Bill.objects.create(owner=user,
creation_date=creation_date, creation_date=creation_date,
@ -310,8 +322,7 @@ class Bill(models.Model):
for order in unpaid_orders['yearly'][next_yearly_bill_start_on]: for order in unpaid_orders['yearly'][next_yearly_bill_start_on]:
order.bill.add(next_yearly_bill) order.bill.add(next_yearly_bill)
# TODO: use logger. logger.info("Generated yearly bill {} (amount: {}) for user {}."
print("Generated yearly bill {} (amount: {}) for user {}."
.format(next_yearly_bill.uuid, next_yearly_bill.total, user)) .format(next_yearly_bill.uuid, next_yearly_bill.total, user))
# Add to output. # Add to output.
@ -361,7 +372,7 @@ class BillRecord():
self.recurring_period = order_record.recurring_period self.recurring_period = order_record.recurring_period
self.description = order_record.description self.description = order_record.description
if self.order.starting_date > self.bill.starting_date: if self.order.starting_date >= self.bill.starting_date:
self.one_time_price = order_record.one_time_price self.one_time_price = order_record.one_time_price
else: else:
self.one_time_price = 0 self.one_time_price = 0
@ -370,7 +381,7 @@ class BillRecord():
def recurring_count(self): def recurring_count(self):
# Compute billing delta. # Compute billing delta.
billed_until = self.bill.ending_date billed_until = self.bill.ending_date
if self.order.ending_date != None and self.order.ending_date < self.order.ending_date: if self.order.ending_date != None and self.order.ending_date <= self.bill.ending_date:
billed_until = self.order.ending_date billed_until = self.order.ending_date
billed_from = self.bill.starting_date billed_from = self.bill.starting_date
@ -387,10 +398,9 @@ class BillRecord():
# TODO: refactor this thing? # TODO: refactor this thing?
# TODO: weekly # TODO: weekly
if self.recurring_period == RecurringPeriod.PER_YEAR: if self.recurring_period == RecurringPeriod.PER_YEAR:
# Should always be one, but let's be careful. # XXX: Should always be one => we do not bill for more than one year.
# FIXME: a year is not exactly 365 days... # TODO: check billed_delta is ~365 days.
years = (billed_delta / timedelta(days=365)) return 1
return Decimal(years)
elif self.recurring_period == RecurringPeriod.PER_MONTH: elif self.recurring_period == RecurringPeriod.PER_MONTH:
days = ceil(billed_delta / timedelta(days=1)) days = ceil(billed_delta / timedelta(days=1))
@ -404,28 +414,28 @@ class BillRecord():
(_, days_in_month) = monthrange( (_, days_in_month) = monthrange(
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 days / days_in_month
elif self.recurring_period == RecurringPeriod.PER_WEEK: elif self.recurring_period == RecurringPeriod.PER_WEEK:
weeks = ceil(billed_delta / timedelta(week=1)) weeks = ceil(billed_delta / timedelta(week=1))
return Decimal(weeks) return 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 days
elif self.recurring_period == RecurringPeriod.PER_HOUR: elif self.recurring_period == RecurringPeriod.PER_HOUR:
hours = ceil(billed_delta / timedelta(hours=1)) hours = ceil(billed_delta / timedelta(hours=1))
return Decimal(hours) return hours
elif self.recurring_period == RecurringPeriod.PER_SECOND: elif self.recurring_period == RecurringPeriod.PER_SECOND:
seconds = ceil(billed_delta / timedelta(seconds=1)) seconds = ceil(billed_delta / timedelta(seconds=1))
return Decimal(seconds) return seconds
elif self.recurring_period == RecurringPeriod.ONE_TIME: elif self.recurring_period == RecurringPeriod.ONE_TIME:
return Decimal(0) return 0
else: else:
raise Exception('Unsupported recurring period: {}.'. raise Exception('Unsupported recurring period: {}.'.
format(record.recurring_period)) format(record.recurring_period))
@property @property
def amount(self): def amount(self):
return self.recurring_price * self.recurring_count + self.one_time_price return Decimal(float(self.recurring_price) * self.recurring_count) + self.one_time_price
### ###
# Orders. # Orders.
@ -440,7 +450,7 @@ class Order(models.Model):
# TODO: enforce ending_date - starting_date to be larger than recurring_period. # TODO: enforce ending_date - starting_date to be larger than recurring_period.
creation_date = models.DateTimeField(auto_now_add=True) creation_date = models.DateTimeField(auto_now_add=True)
starting_date = models.DateTimeField(auto_now_add=True) starting_date = models.DateTimeField()
ending_date = models.DateTimeField(blank=True, ending_date = models.DateTimeField(blank=True,
null=True) null=True)