From 9e8a76bc1b12fac5a6d6f105763643538b4c37ab Mon Sep 17 00:00:00 2001 From: Marc Date: Tue, 2 Sep 2014 15:48:07 +0000 Subject: [PATCH] Initial implementation of billing algorithm --- orchestra/admin/forms.py | 2 +- orchestra/apps/bills/models.py | 16 +++- orchestra/apps/orders/actions.py | 2 +- orchestra/apps/orders/forms.py | 3 +- orchestra/apps/orders/handlers.py | 135 ++++++++++++++++++++++++++++++ orchestra/apps/orders/helpers.py | 41 +++++++++ orchestra/apps/orders/models.py | 19 +++-- orchestra/apps/orders/settings.py | 3 + orchestra/bin/orchestra-admin | 3 +- 9 files changed, 212 insertions(+), 12 deletions(-) diff --git a/orchestra/admin/forms.py b/orchestra/admin/forms.py index 65ae2b33..ccb82a51 100644 --- a/orchestra/admin/forms.py +++ b/orchestra/admin/forms.py @@ -12,7 +12,7 @@ class AdminFormMixin(object): adminform = AdminForm(self, fieldsets, prepopulated_fields) template = Template( '{% for fieldset in adminform %}' - '{% include "admin/includes/fieldset.html" %}' + ' {% include "admin/includes/fieldset.html" %}' '{% endfor %}' ) return template.render(Context({'adminform': adminform})) diff --git a/orchestra/apps/bills/models.py b/orchestra/apps/bills/models.py index 28c069b1..a3895d7c 100644 --- a/orchestra/apps/bills/models.py +++ b/orchestra/apps/bills/models.py @@ -172,14 +172,15 @@ class Budget(Bill): class BaseBillLine(models.Model): + """ Base model for bill item representation """ bill = models.ForeignKey(Bill, verbose_name=_("bill"), related_name='%(class)ss') - description = models.CharField(max_length=256) + description = models.CharField(_("description"), max_length=256) initial_date = models.DateTimeField() final_date = models.DateTimeField() price = models.DecimalField(max_digits=12, decimal_places=2) - amount = models.IntegerField() - tax = models.DecimalField(max_digits=12, decimal_places=2) + amount = models.DecimalField(_("amount"), max_digits=12, decimal_places=2) + tax = models.DecimalField(_("tax"), max_digits=12, decimal_places=2) class Meta: abstract = True @@ -206,4 +207,13 @@ class BillLine(BaseBillLine): 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) diff --git a/orchestra/apps/orders/actions.py b/orchestra/apps/orders/actions.py index 8c858df9..b4310d56 100644 --- a/orchestra/apps/orders/actions.py +++ b/orchestra/apps/orders/actions.py @@ -65,7 +65,7 @@ class BillSelectedOrders(object): def confirmation(self, request): form = BillSelectConfirmationForm(initial=self.options) if request.POST: - bills = Order.bill(queryset, commit=True, **self.options) + bills = self.queryset.bill(commit=True, **self.options) if not bills: msg = _("Selected orders do not have pending billing") self.modeladmin.message_user(request, msg, messages.WARNING) diff --git a/orchestra/apps/orders/forms.py b/orchestra/apps/orders/forms.py index 3a5c9268..bd0275b9 100644 --- a/orchestra/apps/orders/forms.py +++ b/orchestra/apps/orders/forms.py @@ -1,4 +1,5 @@ from django import forms +from django.contrib.admin import widgets from django.utils import timezone from django.utils.translation import ugettext_lazy as _ @@ -9,7 +10,7 @@ from .models import Order class BillSelectedOptionsForm(AdminFormMixin, forms.Form): 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")) fixed_point = forms.BooleanField(initial=False, required=False, label=_("fixed point"), diff --git a/orchestra/apps/orders/handlers.py b/orchestra/apps/orders/handlers.py index 76597327..096c4604 100644 --- a/orchestra/apps/orders/handlers.py +++ b/orchestra/apps/orders/handlers.py @@ -1,10 +1,22 @@ +import calendar + +from dateutil.relativedelta import relativedelta 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 orchestra.utils import plugins +from .helpers import get_register_or_cancel_events, get_register_or_renew_events + class ServiceHandler(plugins.Plugin): + """ + Separates all the logic of billing handling from the model allowing to better + customize its behaviout + """ + model = None __metaclass__ = plugins.PluginMount @@ -38,3 +50,126 @@ class ServiceHandler(plugins.Plugin): instance._meta.model_name: instance } 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 diff --git a/orchestra/apps/orders/helpers.py b/orchestra/apps/orders/helpers.py index f22cc5a3..6fa6a287 100644 --- a/orchestra/apps/orders/helpers.py +++ b/orchestra/apps/orders/helpers.py @@ -36,3 +36,44 @@ def get_related_objects(origin, max_depth=2): new_models.append(related) 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 diff --git a/orchestra/apps/orders/models.py b/orchestra/apps/orders/models.py index 81fe2c93..584b7bbe 100644 --- a/orchestra/apps/orders/models.py +++ b/orchestra/apps/orders/models.py @@ -22,7 +22,7 @@ autodiscover('handlers') class Service(models.Model): - NEVER = 'NEVER' + NEVER = '' MONTHLY = 'MONTHLY' ANUAL = 'ANUAL' TEN_DAYS = 'TEN_DAYS' @@ -36,6 +36,7 @@ class Service(models.Model): NOTHING = 'NOTHING' DISCOUNT = 'DISCOUNT' REFOUND = 'REFOUND' + COMPENSATE = 'COMPENSATE' PREPAY = 'PREPAY' POSTPAY = 'POSTPAY' BEST_PRICE = 'BEST_PRICE' @@ -59,7 +60,7 @@ class Service(models.Model): (MONTHLY, _("Monthly billing")), (ANUAL, _("Anual billing")), ), - default=ANUAL) + default=ANUAL, blank=True) billing_point = models.CharField(_("billing point"), max_length=16, help_text=_("Reference point for calculating the renewal date " "on recurring invoices"), @@ -75,7 +76,7 @@ class Service(models.Model): (TEN_DAYS, _("Ten days")), (ONE_MONTH, _("One month")), ), - default=ONE_MONTH) + default=ONE_MONTH, blank=True) is_fee = models.BooleanField(_("is fee"), default=False, help_text=_("Designates whether this service should be billed as " " membership fee or not")) @@ -115,7 +116,8 @@ class Service(models.Model): choices=( (NOTHING, _("Nothing")), (DISCOUNT, _("Discount")), - (REFOUND, _("Refound")), + (COMPENSATE, _("Discount and compensate")), + (REFOUND, _("Discount, compensate and refound")), ), default=DISCOUNT) # TODO remove, orders are not disabled (they are cancelled user.is_active) @@ -159,7 +161,7 @@ class Service(models.Model): (ONE_MONTH, _("One month")), (ALWAYS, _("Always refound")), ), - default=ONE_MONTH) + default=NEVER) def __unicode__(self): return self.description @@ -212,6 +214,13 @@ class Service(models.Model): message = exception.message msg = "{0} {1}: {2}".format(attr, name, message) 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): diff --git a/orchestra/apps/orders/settings.py b/orchestra/apps/orders/settings.py index e8b41f7e..83c438e7 100644 --- a/orchestra/apps/orders/settings.py +++ b/orchestra/apps/orders/settings.py @@ -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_ANUAL_BILLING_MONTH = getattr(settings, 'ORDERS_SERVICE_ANUAL_BILLING_MONTH', 4) diff --git a/orchestra/bin/orchestra-admin b/orchestra/bin/orchestra-admin index 5a7292e2..8f487d7e 100755 --- a/orchestra/bin/orchestra-admin +++ b/orchestra/bin/orchestra-admin @@ -149,7 +149,8 @@ function install_requirements () { django-filter==0.7 \ passlib==1.6.2 \ jsonfield==0.9.22 \ - lxml==3.3.5" + lxml==3.3.5 \ + python-dateutil==2.2" if $testing; then APT="${APT} \