Initial implementation of billing algorithm
This commit is contained in:
parent
9a24ee5ae1
commit
9e8a76bc1b
|
@ -12,7 +12,7 @@ class AdminFormMixin(object):
|
||||||
adminform = AdminForm(self, fieldsets, prepopulated_fields)
|
adminform = AdminForm(self, fieldsets, prepopulated_fields)
|
||||||
template = Template(
|
template = Template(
|
||||||
'{% for fieldset in adminform %}'
|
'{% for fieldset in adminform %}'
|
||||||
'{% include "admin/includes/fieldset.html" %}'
|
' {% include "admin/includes/fieldset.html" %}'
|
||||||
'{% endfor %}'
|
'{% endfor %}'
|
||||||
)
|
)
|
||||||
return template.render(Context({'adminform': adminform}))
|
return template.render(Context({'adminform': adminform}))
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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"),
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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} \
|
||||||
|
|
Loading…
Reference in a new issue