Handle edge cases of last day of the month of billing period.

This commit is contained in:
Santiago L 2021-03-31 12:11:53 +02:00
parent dc722ec17a
commit 2b06652a5b

View file

@ -21,29 +21,29 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
""" """
Separates all the logic of billing handling from the model allowing to better Separates all the logic of billing handling from the model allowing to better
customize its behaviout customize its behaviout
Relax and enjoy the journey. Relax and enjoy the journey.
""" """
_PLAN = 'plan' _PLAN = 'plan'
_COMPENSATION = 'compensation' _COMPENSATION = 'compensation'
_PREPAY = 'prepay' _PREPAY = 'prepay'
model = None model = None
def __init__(self, service): def __init__(self, service):
self.service = service self.service = service
def __getattr__(self, attr): def __getattr__(self, attr):
return getattr(self.service, attr) return getattr(self.service, attr)
@classmethod @classmethod
def get_choices(cls): def get_choices(cls):
choices = super(ServiceHandler, cls).get_choices() choices = super(ServiceHandler, cls).get_choices()
return [('', _("Default"))] + choices return [('', _("Default"))] + choices
def validate_content_type(self, service): def validate_content_type(self, service):
pass pass
def validate_expression(self, service, method): def validate_expression(self, service, method):
try: try:
obj = service.content_type.model_class().objects.all()[0] obj = service.content_type.model_class().objects.all()[0]
@ -53,24 +53,24 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
bool(getattr(self, method)(obj)) bool(getattr(self, method)(obj))
except Exception as exc: except Exception as exc:
raise ValidationError(format_exception(exc)) raise ValidationError(format_exception(exc))
def validate_match(self, service): def validate_match(self, service):
if not service.match: if not service.match:
service.match = 'True' service.match = 'True'
self.validate_expression(service, 'matches') self.validate_expression(service, 'matches')
def validate_metric(self, service): def validate_metric(self, service):
self.validate_expression(service, 'get_metric') self.validate_expression(service, 'get_metric')
def validate_order_description(self, service): def validate_order_description(self, service):
self.validate_expression(service, 'get_order_description') self.validate_expression(service, 'get_order_description')
def get_content_type(self): def get_content_type(self):
if not self.model: if not self.model:
return self.content_type return self.content_type
app_label, model = self.model.split('.') app_label, model = self.model.split('.')
return ContentType.objects.get_by_natural_key(app_label, model.lower()) return ContentType.objects.get_by_natural_key(app_label, model.lower())
def get_expression_context(self, instance): def get_expression_context(self, instance):
return { return {
'instance': instance, 'instance': instance,
@ -85,14 +85,14 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
'log10': math.log10, 'log10': math.log10,
'Decimal': decimal.Decimal, 'Decimal': decimal.Decimal,
} }
def matches(self, instance): def matches(self, instance):
if not self.match: if not self.match:
# Blank expressions always evaluate True # Blank expressions always evaluate True
return True return True
safe_locals = self.get_expression_context(instance) safe_locals = self.get_expression_context(instance)
return eval(self.match, safe_locals) return eval(self.match, safe_locals)
def get_ignore_delta(self): def get_ignore_delta(self):
if self.ignore_period == self.NEVER: if self.ignore_period == self.NEVER:
return None return None
@ -104,14 +104,14 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
return datetime.timedelta(months=value) return datetime.timedelta(months=value)
else: else:
raise ValueError("Unknown unit %s" % unit) raise ValueError("Unknown unit %s" % unit)
def get_order_ignore(self, order): def get_order_ignore(self, order):
""" service trial delta """ """ service trial delta """
ignore_delta = self.get_ignore_delta() ignore_delta = self.get_ignore_delta()
if ignore_delta and (order.cancelled_on-ignore_delta).date() <= order.registered_on: if ignore_delta and (order.cancelled_on-ignore_delta).date() <= order.registered_on:
return True return True
return order.ignore return order.ignore
def get_ignore(self, instance): def get_ignore(self, instance):
if self.ignore_superusers: if self.ignore_superusers:
account = getattr(instance, 'account', instance) account = getattr(instance, 'account', instance)
@ -120,7 +120,7 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
if 'superuser' in settings.SERVICES_IGNORE_ACCOUNT_TYPE and account.is_superuser: if 'superuser' in settings.SERVICES_IGNORE_ACCOUNT_TYPE and account.is_superuser:
return True return True
return False return False
def get_metric(self, instance): def get_metric(self, instance):
if self.metric: if self.metric:
safe_locals = self.get_expression_context(instance) safe_locals = self.get_expression_context(instance)
@ -128,7 +128,7 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
return eval(self.metric, safe_locals) return eval(self.metric, safe_locals)
except Exception as exc: except Exception as exc:
raise type(exc)("'%s' evaluating metric for '%s' service" % (exc, self.service)) raise type(exc)("'%s' evaluating metric for '%s' service" % (exc, self.service))
def get_order_description(self, instance): def get_order_description(self, instance):
safe_locals = self.get_expression_context(instance) safe_locals = self.get_expression_context(instance)
account = getattr(instance, 'account', instance) account = getattr(instance, 'account', instance)
@ -136,7 +136,7 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
if not self.order_description: if not self.order_description:
return '%s: %s' % (ugettext(self.description), instance) return '%s: %s' % (ugettext(self.description), instance)
return eval(self.order_description, safe_locals) return eval(self.order_description, safe_locals)
def get_billing_point(self, order, bp=None, **options): def get_billing_point(self, order, bp=None, **options):
cachable = bool(self.billing_point == self.FIXED_DATE and not options.get('fixed_point')) cachable = bool(self.billing_point == self.FIXED_DATE and not options.get('fixed_point'))
if not cachable or bp is None: if not cachable or bp is None:
@ -151,7 +151,10 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
else: else:
date = timezone.now().date() date = timezone.now().date()
if self.billing_point == self.ON_REGISTER: if self.billing_point == self.ON_REGISTER:
day = order.registered_on.day # handle edge cases of last day of the month:
# e.g. on March is 31 but on April 30
last_day_of_month = calendar.monthrange(date.year, date.month)[1]
day = min(last_day_of_month, order.registered_on.day)
elif self.billing_point == self.FIXED_DATE: elif self.billing_point == self.FIXED_DATE:
day = 1 day = 1
else: else:
@ -171,6 +174,11 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
year = bp.year - relativedelta.relativedelta(years=1) year = bp.year - relativedelta.relativedelta(years=1)
if bp.month >= month: if bp.month >= month:
year = bp.year + 1 year = bp.year + 1
# handle edge cases of last day of the month:
# e.g. on March is 31 but on April 30
last_day_of_month = calendar.monthrange(year,month)[1]
day = min(last_day_of_month, day)
bp = datetime.date(year=year, month=month, day=day) bp = datetime.date(year=year, month=month, day=day)
elif self.billing_period == self.NEVER: elif self.billing_period == self.NEVER:
bp = order.registered_on bp = order.registered_on
@ -179,7 +187,7 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
if self.on_cancel != self.NOTHING and order.cancelled_on and order.cancelled_on < bp: if self.on_cancel != self.NOTHING and order.cancelled_on and order.cancelled_on < bp:
bp = order.cancelled_on bp = order.cancelled_on
return bp return bp
# def aligned(self, date): # def aligned(self, date):
# if self.granularity == self.DAILY: # if self.granularity == self.DAILY:
# return date # return date
@ -188,7 +196,7 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
# elif self.granularity == self.ANUAL: # elif self.granularity == self.ANUAL:
# return datetime.date(year=date.year, month=1, day=1) # return datetime.date(year=date.year, month=1, day=1)
# raise NotImplementedError # raise NotImplementedError
def get_price_size(self, ini, end): def get_price_size(self, ini, end):
rdelta = relativedelta.relativedelta(end, ini) rdelta = relativedelta.relativedelta(end, ini)
anual_prepay_of_monthly_pricing = bool( anual_prepay_of_monthly_pricing = bool(
@ -211,7 +219,7 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
raise NotImplementedError raise NotImplementedError
size = round(size, 2) size = round(size, 2)
return decimal.Decimal(str(size)) return decimal.Decimal(str(size))
def get_pricing_slots(self, ini, end): def get_pricing_slots(self, ini, end):
day = 1 day = 1
month = settings.SERVICES_SERVICE_ANUAL_BILLING_MONTH month = settings.SERVICES_SERVICE_ANUAL_BILLING_MONTH
@ -235,7 +243,7 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
if next >= end: if next >= end:
break break
ini = next ini = next
def get_pricing_rdelta(self): def get_pricing_rdelta(self):
period = self.get_pricing_period() period = self.get_pricing_period()
if period == self.MONTHLY: if period == self.MONTHLY:
@ -244,13 +252,13 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
return relativedelta.relativedelta(years=1) return relativedelta.relativedelta(years=1)
elif period == self.NEVER: elif period == self.NEVER:
return None return None
def generate_discount(self, line, dtype, price): def generate_discount(self, line, dtype, price):
line.discounts.append(AttrDict(**{ line.discounts.append(AttrDict(**{
'type': dtype, 'type': dtype,
'total': price, 'total': price,
})) }))
def generate_line(self, order, price, *dates, metric=1, discounts=None, computed=False): def generate_line(self, order, price, *dates, metric=1, discounts=None, computed=False):
""" """
discounts: extra discounts to apply discounts: extra discounts to apply
@ -263,7 +271,7 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
else: else:
raise AttributeError("WTF is '%s'?" % dates) raise AttributeError("WTF is '%s'?" % dates)
discounts = discounts or () discounts = discounts or ()
size = self.get_price_size(ini, end) size = self.get_price_size(ini, end)
if not computed: if not computed:
price = price * size price = price * size
@ -277,7 +285,7 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
'metric': metric, 'metric': metric,
'discounts': [], 'discounts': [],
}) })
if subtotal > price: if subtotal > price:
plan_discount = price-subtotal plan_discount = price-subtotal
self.generate_discount(line, self._PLAN, plan_discount) self.generate_discount(line, self._PLAN, plan_discount)
@ -290,7 +298,7 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
if dprice: if dprice:
self.generate_discount(line, dtype, dprice) self.generate_discount(line, dtype, dprice)
return line return line
def assign_compensations(self, givers, receivers, **options): def assign_compensations(self, givers, receivers, **options):
compensations = [] compensations = []
for order in givers: for order in givers:
@ -313,7 +321,7 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
if hasattr(order, 'new_billed_until'): if hasattr(order, 'new_billed_until'):
order.billed_until = order.new_billed_until order.billed_until = order.new_billed_until
order.save(update_fields=['billed_until']) order.save(update_fields=['billed_until'])
def apply_compensations(self, order, only_beyond=False): def apply_compensations(self, order, only_beyond=False):
dsize = 0 dsize = 0
ini = order.billed_until or order.registered_on ini = order.billed_until or order.registered_on
@ -339,7 +347,7 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
new_end = cend new_end = cend
dsize += self.get_price_size(comp.ini, cend) dsize += self.get_price_size(comp.ini, cend)
return dsize, new_end return dsize, new_end
def get_register_or_renew_events(self, porders, ini, end): def get_register_or_renew_events(self, porders, ini, end):
counter = 0 counter = 0
for order in porders: for order in porders:
@ -354,7 +362,7 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
if registered != order.billed_until and order.billed_until > ini and order.billed_until <= end: if registered != order.billed_until and order.billed_until > ini and order.billed_until <= end:
counter += 1 counter += 1
return counter return counter
def bill_concurrent_orders(self, account, porders, rates, ini, end): def bill_concurrent_orders(self, account, porders, rates, ini, end):
# Concurrent # Concurrent
# Get pricing orders # Get pricing orders
@ -403,7 +411,7 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
order, price, ini, end, discounts=discounts, computed=True) order, price, ini, end, discounts=discounts, computed=True)
lines.append(line) lines.append(line)
return lines return lines
def bill_registered_or_renew_events(self, account, porders, rates): def bill_registered_or_renew_events(self, account, porders, rates):
# Before registration # Before registration
lines = [] lines = []
@ -431,7 +439,7 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
line = self.generate_line(order, price, ini, end, discounts=discounts) line = self.generate_line(order, price, ini, end, discounts=discounts)
lines.append(line) lines.append(line)
return lines return lines
def bill_with_orders(self, orders, account, **options): def bill_with_orders(self, orders, account, **options):
# For the "boundary conditions" just think that: # For the "boundary conditions" just think that:
# date(2011, 1, 1) is equivalent to datetime(2011, 1, 1, 0, 0, 0) # date(2011, 1, 1) is equivalent to datetime(2011, 1, 1, 0, 0, 0)
@ -458,7 +466,7 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
end = max(end, bp) end = max(end, bp)
orders_.append(order) orders_.append(order)
orders = orders_ orders = orders_
# Compensation # Compensation
related_orders = account.orders.filter(service=self.service) related_orders = account.orders.filter(service=self.service)
if self.payment_style == self.PREPAY and self.on_cancel == self.COMPENSATE: if self.payment_style == self.PREPAY and self.on_cancel == self.COMPENSATE:
@ -504,7 +512,7 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
line = self.generate_line(order, price, ini, end, discounts=discounts) line = self.generate_line(order, price, ini, end, discounts=discounts)
lines.append(line) lines.append(line)
return lines return lines
def bill_with_metric(self, orders, account, **options): def bill_with_metric(self, orders, account, **options):
lines = [] lines = []
bp = None bp = None
@ -512,7 +520,7 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
prepay_discount = 0 prepay_discount = 0
bp = self.get_billing_point(order, bp=bp, **options) bp = self.get_billing_point(order, bp=bp, **options)
recharged_until = datetime.date.min recharged_until = datetime.date.min
if (self.billing_period != self.NEVER and if (self.billing_period != self.NEVER and
self.get_pricing_period() == self.NEVER and self.get_pricing_period() == self.NEVER and
self.payment_style == self.PREPAY and order.billed_on): self.payment_style == self.PREPAY and order.billed_on):
@ -633,7 +641,7 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
# Last processed metric for futrue recharges # Last processed metric for futrue recharges
order.new_billed_metric = metric order.new_billed_metric = metric
return lines return lines
def generate_bill_lines(self, orders, account, **options): def generate_bill_lines(self, orders, account, **options):
if options.get('proforma', False): if options.get('proforma', False):
options['commit'] = False options['commit'] = False