Initial implementation of billing algorithm

This commit is contained in:
Marc 2014-09-02 15:48:07 +00:00
parent 9a24ee5ae1
commit 9e8a76bc1b
9 changed files with 212 additions and 12 deletions

View File

@ -172,14 +172,15 @@ class Budget(Bill):
class BaseBillLine(models.Model): class BaseBillLine(models.Model):
""" Base model for bill item representation """
bill = models.ForeignKey(Bill, verbose_name=_("bill"), bill = models.ForeignKey(Bill, verbose_name=_("bill"),
related_name='%(class)ss') related_name='%(class)ss')
description = models.CharField(max_length=256) description = models.CharField(_("description"), max_length=256)
initial_date = models.DateTimeField() initial_date = models.DateTimeField()
final_date = models.DateTimeField() final_date = models.DateTimeField()
price = models.DecimalField(max_digits=12, decimal_places=2) price = models.DecimalField(max_digits=12, decimal_places=2)
amount = models.IntegerField() amount = models.DecimalField(_("amount"), max_digits=12, decimal_places=2)
tax = models.DecimalField(max_digits=12, decimal_places=2) tax = models.DecimalField(_("tax"), max_digits=12, decimal_places=2)
class Meta: class Meta:
abstract = True abstract = True
@ -206,4 +207,13 @@ class BillLine(BaseBillLine):
related_name='amendment_lines', null=True, blank=True) related_name='amendment_lines', null=True, blank=True)
class SubBillLine(models.Model):
""" Subline used for describing an item discount """
bill_line = models.ForeignKey(BillLine, verbose_name=_("bill line"),
related_name='sublines')
description = models.CharField(_("description"), max_length=256)
total = models.DecimalField(max_digits=12, decimal_places=2)
# TODO type ? Volume and Compensation
accounts.register(Bill) accounts.register(Bill)

View File

@ -65,7 +65,7 @@ class BillSelectedOrders(object):
def confirmation(self, request): def confirmation(self, request):
form = BillSelectConfirmationForm(initial=self.options) form = BillSelectConfirmationForm(initial=self.options)
if request.POST: if request.POST:
bills = Order.bill(queryset, commit=True, **self.options) bills = self.queryset.bill(commit=True, **self.options)
if not bills: if not bills:
msg = _("Selected orders do not have pending billing") msg = _("Selected orders do not have pending billing")
self.modeladmin.message_user(request, msg, messages.WARNING) self.modeladmin.message_user(request, msg, messages.WARNING)

View File

@ -1,4 +1,5 @@
from django import forms from django import forms
from django.contrib.admin import widgets
from django.utils import timezone from django.utils import timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -9,7 +10,7 @@ from .models import Order
class BillSelectedOptionsForm(AdminFormMixin, forms.Form): class BillSelectedOptionsForm(AdminFormMixin, forms.Form):
billing_point = forms.DateField(initial=timezone.now, billing_point = forms.DateField(initial=timezone.now,
label=_("Billing point"), label=_("Billing point"), widget=widgets.AdminDateWidget,
help_text=_("Date you want to bill selected orders")) help_text=_("Date you want to bill selected orders"))
fixed_point = forms.BooleanField(initial=False, required=False, fixed_point = forms.BooleanField(initial=False, required=False,
label=_("fixed point"), label=_("fixed point"),

View File

@ -1,10 +1,22 @@
import calendar
from dateutil.relativedelta import relativedelta
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from orchestra.utils import plugins from orchestra.utils import plugins
from .helpers import get_register_or_cancel_events, get_register_or_renew_events
class ServiceHandler(plugins.Plugin): class ServiceHandler(plugins.Plugin):
"""
Separates all the logic of billing handling from the model allowing to better
customize its behaviout
"""
model = None model = None
__metaclass__ = plugins.PluginMount __metaclass__ = plugins.PluginMount
@ -38,3 +50,126 @@ class ServiceHandler(plugins.Plugin):
instance._meta.model_name: instance instance._meta.model_name: instance
} }
return eval(self.metric, safe_locals) return eval(self.metric, safe_locals)
def get_billing_point(self, order, bp=None, **options):
not_cachable = self.billing_point is self.FIXED_DATE and options.get('fixed_point')
if not_cachable or bp is None:
bp = options.get('billing_point', timezone.now().date())
if not options.get('fixed_point'):
if self.billing_period is self.MONTHLY:
date = bp
if self.payment_style is self.PREPAY:
date += relativedelta(months=1)
if self.billing_point is self.ON_REGISTER:
day = order.registered_on.day
elif self.billing_point is self.FIXED_DATE:
day = 1
bp = datetime.datetime(year=date.year, month=date.month,
day=day, tzinfo=timezone.get_current_timezone())
elif self.billing_period is self.ANUAL:
if self.billing_point is self.ON_REGISTER:
month = order.registered_on.month
day = order.registered_on.day
elif self.billing_point is self.FIXED_DATE:
month = settings.ORDERS_SERVICE_ANUAL_BILLING_MONTH
day = 1
year = bp.year
if self.payment_style is self.POSTPAY:
year = bo.year - relativedelta(years=1)
if bp.month >= month:
year = bp.year + 1
bp = datetime.datetime(year=year, month=month, day=day,
tzinfo=timezone.get_current_timezone())
elif self.billing_period is self.NEVER:
bp = order.registered_on
else:
raise NotImplementedError(
"Support for '%s' billing period and '%s' billing point is not implemented"
% (self.display_billing_period(), self.display_billing_point())
)
if self.on_cancel is not self.NOTHING and order.cancelled_on < bp:
return order.cancelled_on
return bp
def get_pricing_size(self, ini, end):
rdelta = relativedelta.relativedelta(end, ini)
if self.get_pricing_period() is self.MONTHLY:
size = rdelta.months
days = calendar.monthrange(bp.year, bp.month)[1]
size += float(bp.day)/days
elif self.get_pricint_period() is self.ANUAL:
size = rdelta.years
size += float(rdelta.days)/365
elif self.get_pricing_period() is self.NEVER:
size = 1
else:
raise NotImplementedError
return size
def get_pricing_slots(self, ini, end):
period = self.get_pricing_period()
if period is self.MONTHLY:
rdelta = relativedelta(months=1)
elif period is self.ANUAL:
rdelta = relativedelta(years=1)
elif period is self.NEVER:
yield ini, end
raise StopIteration
else:
raise NotImplementedError
while True:
next = ini + rdelta
if next >= end:
yield ini, end
break
yield ini, next
ini = next
def create_line(self, order, price, size):
nominal_price = self.nominal_price * size
if nominal_price > price:
discount = nominal_price-price
def create_bill_lines(self, orders, **options):
# Perform compensations on cancelled services
# TODO WTF to do with day 1 of each month.
if self.on_cancel in (Order.COMPENSATE, Order.REFOUND):
# TODO compensations with commit=False, fuck commit or just fuck the transaction?
compensate(orders, **options)
# TODO create discount per compensation
bp = None
lines = []
for order in orders:
bp = self.get_billing_point(order, bp=bp, **options)
ini = order.billed_until or order.registered_on
if bp < ini:
continue
if not self.metric:
# Number of orders metric; bill line per order
porders = service.orders.filter(account=order.account).filter(
Q(is_active=True) | Q(cancelled_on__gt=order.billed_until)
).filter(registered_on__lt=bp)
price = 0
size = self.get_pricing_size(ini, bp)
if self.orders_effect is self.REGISTER_OR_RENEW:
events = get_register_or_renew_events(porders, ini, bp)
elif self.orders_effect is self.CONCURRENT:
events = get_register_or_cancel_events(porders, ini, bp)
else:
raise NotImplementedError
for metric, ratio in events:
price += self.get_rate(metric, account) * size * ratio
lines += self.create_line(order, price, size)
else:
# weighted metric; bill line per pricing period
for ini, end in self.get_pricing_slots(ini, bp):
metric = order.get_metric(ini, end)
size = self.get_pricing_size(ini, end)
price = self.get_rate(metric, account) * size
lines += self.create_line(order, price, size)
return lines
def compensate(self, orders):
# num orders and weights
# Discounts
pass

View File

@ -36,3 +36,44 @@ def get_related_objects(origin, max_depth=2):
new_models.append(related) new_models.append(related)
queue.append(new_models) queue.append(new_models)
def get_register_or_cancel_events(porders, ini, end):
assert ini > end, "ini > end"
CANCEL = 'cancel'
REGISTER = 'register'
changes = {}
counter = 0
for order in porders:
if order.cancelled_on:
cancel = order.cancelled_on
if order.billed_until and order.cancelled_on < order.billed_until:
cancel = order.billed_until
if cancel > ini and cancel < end:
changes.setdefault(cancel, [])
changes[cancel].append(CANCEL)
if order.registered_on < ini:
counter += 1
elif order.registered_on < end:
changes.setdefault(order.registered_on, [])
changes[order.registered_on].append(REGISTER)
pointer = ini
total = float((end-ini).days)
for date in changes.keys().sort():
for change in changes[date]:
if change is CANCEL:
counter -= 1
else:
counter += 1
yield counter, (date-pointer).days/total
pointer = date
def get_register_or_renew_events(handler, porders, ini, end):
total = float((end-ini).days)
for sini, send in handler.get_pricing_slots(ini, end):
counter = 0
for order in porders:
if order.registered_on > sini and order.registered_on < send:
counter += 1
elif order.billed_until > send or order.cancelled_on > send:
counter += 1
yield counter, (send-sini)/total

View File

@ -22,7 +22,7 @@ autodiscover('handlers')
class Service(models.Model): class Service(models.Model):
NEVER = 'NEVER' NEVER = ''
MONTHLY = 'MONTHLY' MONTHLY = 'MONTHLY'
ANUAL = 'ANUAL' ANUAL = 'ANUAL'
TEN_DAYS = 'TEN_DAYS' TEN_DAYS = 'TEN_DAYS'
@ -36,6 +36,7 @@ class Service(models.Model):
NOTHING = 'NOTHING' NOTHING = 'NOTHING'
DISCOUNT = 'DISCOUNT' DISCOUNT = 'DISCOUNT'
REFOUND = 'REFOUND' REFOUND = 'REFOUND'
COMPENSATE = 'COMPENSATE'
PREPAY = 'PREPAY' PREPAY = 'PREPAY'
POSTPAY = 'POSTPAY' POSTPAY = 'POSTPAY'
BEST_PRICE = 'BEST_PRICE' BEST_PRICE = 'BEST_PRICE'
@ -59,7 +60,7 @@ class Service(models.Model):
(MONTHLY, _("Monthly billing")), (MONTHLY, _("Monthly billing")),
(ANUAL, _("Anual billing")), (ANUAL, _("Anual billing")),
), ),
default=ANUAL) default=ANUAL, blank=True)
billing_point = models.CharField(_("billing point"), max_length=16, billing_point = models.CharField(_("billing point"), max_length=16,
help_text=_("Reference point for calculating the renewal date " help_text=_("Reference point for calculating the renewal date "
"on recurring invoices"), "on recurring invoices"),
@ -75,7 +76,7 @@ class Service(models.Model):
(TEN_DAYS, _("Ten days")), (TEN_DAYS, _("Ten days")),
(ONE_MONTH, _("One month")), (ONE_MONTH, _("One month")),
), ),
default=ONE_MONTH) default=ONE_MONTH, blank=True)
is_fee = models.BooleanField(_("is fee"), default=False, is_fee = models.BooleanField(_("is fee"), default=False,
help_text=_("Designates whether this service should be billed as " help_text=_("Designates whether this service should be billed as "
" membership fee or not")) " membership fee or not"))
@ -115,7 +116,8 @@ class Service(models.Model):
choices=( choices=(
(NOTHING, _("Nothing")), (NOTHING, _("Nothing")),
(DISCOUNT, _("Discount")), (DISCOUNT, _("Discount")),
(REFOUND, _("Refound")), (COMPENSATE, _("Discount and compensate")),
(REFOUND, _("Discount, compensate and refound")),
), ),
default=DISCOUNT) default=DISCOUNT)
# TODO remove, orders are not disabled (they are cancelled user.is_active) # TODO remove, orders are not disabled (they are cancelled user.is_active)
@ -159,7 +161,7 @@ class Service(models.Model):
(ONE_MONTH, _("One month")), (ONE_MONTH, _("One month")),
(ALWAYS, _("Always refound")), (ALWAYS, _("Always refound")),
), ),
default=ONE_MONTH) default=NEVER)
def __unicode__(self): def __unicode__(self):
return self.description return self.description
@ -213,6 +215,13 @@ class Service(models.Model):
msg = "{0} {1}: {2}".format(attr, name, message) msg = "{0} {1}: {2}".format(attr, name, message)
raise ValidationError(msg) raise ValidationError(msg)
def get_nominal_price(self, order):
""" returns the price of an item """
def get_price(self, order, amount='TODO'):
pass
class OrderQuerySet(models.QuerySet): class OrderQuerySet(models.QuerySet):
group_by = queryset.group_by group_by = queryset.group_by

View File

@ -9,3 +9,6 @@ ORDERS_SERVICE_TAXES = getattr(settings, 'ORDERS_SERVICE_TAXES', (
)) ))
ORDERS_SERVICE_DEFAUL_TAX = getattr(settings, 'ORDERS_SERVICE_DFAULT_TAX', 0) ORDERS_SERVICE_DEFAUL_TAX = getattr(settings, 'ORDERS_SERVICE_DFAULT_TAX', 0)
ORDERS_SERVICE_ANUAL_BILLING_MONTH = getattr(settings, 'ORDERS_SERVICE_ANUAL_BILLING_MONTH', 4)

View File

@ -149,7 +149,8 @@ function install_requirements () {
django-filter==0.7 \ django-filter==0.7 \
passlib==1.6.2 \ passlib==1.6.2 \
jsonfield==0.9.22 \ jsonfield==0.9.22 \
lxml==3.3.5" lxml==3.3.5 \
python-dateutil==2.2"
if $testing; then if $testing; then
APT="${APT} \ APT="${APT} \