import calendar import datetime from dateutil 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 orchestra.utils.python import AttributeDict from . import settings, helpers 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 def __init__(self, service): self.service = service def __getattr__(self, attr): return getattr(self.service, attr) @classmethod def get_plugin_choices(cls): choices = super(ServiceHandler, cls).get_plugin_choices() return [('', _("Default"))] + choices def get_content_type(self): if not self.model: return self.content_type app_label, model = self.model.split('.') return ContentType.objects.get_by_natural_key(app_label, model.lower()) def matches(self, instance): safe_locals = { instance._meta.model_name: instance } return eval(self.match, safe_locals) def get_metric(self, instance): if self.metric: safe_locals = { 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 == 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'): msg = ("Support for '%s' period and '%s' point is not implemented" % (self.get_billing_period_display(), self.get_billing_point_display())) if self.billing_period == self.MONTHLY: date = bp if self.payment_style == self.PREPAY: date += relativedelta.relativedelta(months=1) if self.billing_point == self.ON_REGISTER: day = order.registered_on.day elif self.billing_point == self.FIXED_DATE: day = 1 else: raise NotImplementedError(msg) bp = datetime.datetime(year=date.year, month=date.month, day=day, tzinfo=timezone.get_current_timezone()) elif self.billing_period == self.ANUAL: if self.billing_point == self.ON_REGISTER: month = order.registered_on.month day = order.registered_on.day elif self.billing_point == self.FIXED_DATE: month = settings.ORDERS_SERVICE_ANUAL_BILLING_MONTH day = 1 else: raise NotImplementedError(msg) year = bp.year if self.payment_style == self.POSTPAY: year = bo.year - relativedelta.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 == self.NEVER: bp = order.registered_on else: raise NotImplementedError(msg) if self.on_cancel != self.NOTHING and order.cancelled_on 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() == self.MONTHLY: size = rdelta.months days = calendar.monthrange(end.year, end.month)[1] size += float(rdelta.days)/days elif self.get_pricing_period() == self.ANUAL: size = rdelta.years days = 366 if calendar.isleap(end.year) else 365 size += float((end-ini).days)/days elif self.get_pricing_period() == self.NEVER: size = 1 else: raise NotImplementedError return size def get_pricing_slots(self, ini, end): period = self.get_pricing_period() if period == self.MONTHLY: rdelta = relativedelta.relativedelta(months=1) elif period == self.ANUAL: rdelta = relativedelta.relativedelta(years=1) elif period == 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 get_price_with_orders(self, order, size, ini, end): porders = self.orders.filter(account=order.account).filter( Q(cancelled_on__isnull=True) | Q(cancelled_on__gt=ini) ).filter(registered_on__lt=end).order_by('registered_on') price = 0 if self.orders_effect == self.REGISTER_OR_RENEW: events = helpers.get_register_or_renew_events(porders, order, ini, end) elif self.orders_effect == self.CONCURRENT: events = helpers.get_register_or_cancel_events(porders, order, ini, end) else: raise NotImplementedError for metric, position, ratio in events: price += self.get_price(order, metric, position=position) * size * ratio return price def get_price_with_metric(self, order, size, ini, end): metric = order.get_metric(ini, end) price = self.get_price(order, metric) * size return price def generate_line(self, order, price, size, ini, end): subtotal = float(self.nominal_price) * size discounts = [] if subtotal > price: discounts.append(AttributeDict(**{ 'type': 'volume', 'total': price-subtotal })) elif subtotal < price: raise ValueError("Something is wrong!") return AttributeDict(**{ 'order': order, 'subtotal': subtotal, 'size': size, 'ini': ini, 'end': end, 'discounts': discounts, }) def _generate_bill_lines(self, orders, **options): # For the "boundary conditions" just think that: # date(2011, 1, 1) is equivalent to datetime(2011, 1, 1, 0, 0, 0) # In most cases: # ini >= registered_date, end < registered_date # TODO Perform compensations on cancelled services if self.on_cancel in (self.COMPENSATE, self.REFOUND): pass # TODO compensations with commit=False, fuck commit or just fuck the transaction? # compensate(orders, **options) # TODO create discount per compensation bp = None lines = [] commit = options.get('commit', True) ini = datetime.date.max end = datetime.date.ini # boundary lookup for order in orders: cini = order.registered_on if order.billed_until: cini = order.billed_until bp = self.get_billing_point(order, bp=bp, **options) order.new_billed_until = bp ini = min(ini, cini) end = max(end, bp) # TODO if all bp are the same ... porders = orders.pricing_orders(ini=ini, end=end) porders.sort(cmp=helpers.cmp_billed_until_or_registered_on) # Compensation compensations = [] receivers = [] for order in porders: if order.billed_until and order.cancelled_on and order.cancelled_on < order.billed_until: compensations.append[Interval(order.cancelled_on, order.billed_until, order)] orders.sort(cmp=helpers.cmp_billed_until_or_registered_on) for order in orders: order_interval = Interval(order.billed_until or order.registered_on, order.new_billed_until) helpers.compensate(order_interval, compensations) def get_chunks(self, porders, ini, end, ix=0): if ix >= len(porders): return [[ini, end, []]] order = porders[ix] ix += 1 bu = getattr(order, 'new_billed_until', order.billed_until) if not bu or bu <= ini or order.registered_on >= end: return self.get_chunks(porders, ini, end, ix=ix) result = [] if order.registered_on < end and order.registered_on > ini: ro = order.registered_on result = self.get_chunks(porders, ini, ro, ix=ix) ini = ro if bu < end: result += self.get_chunks(porders, bu, end, ix=ix) end = bu chunks = self.get_chunks(porders, ini, end, ix=ix) for chunk in chunks: chunk[2].insert(0, order) result.append(chunk) return result def generate_bill_lines(self, orders, **options): # For the "boundary conditions" just think that: # date(2011, 1, 1) is equivalent to datetime(2011, 1, 1, 0, 0, 0) # In most cases: # ini >= registered_date, end < registered_date # TODO Perform compensations on cancelled services if self.on_cancel in (self.COMPENSATE, self.REFOUND): pass # TODO compensations with commit=False, fuck commit or just fuck the transaction? # compensate(orders, **options) # TODO create discount per compensation bp = None lines = [] commit = options.get('commit', True) 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 size = self.get_pricing_size(ini, bp) price = self.get_price_with_orders(order, size, ini, bp) lines.append(self.generate_line(order, price, size, ini, bp)) else: # weighted metric; bill line per pricing period for ini, end in self.get_pricing_slots(ini, bp): size = self.get_pricing_size(ini, end) price = self.get_price_with_metric(order, size, ini, end) lines.append(self.generate_line(order, price, size, ini, end)) order.billed_until = bp if commit: order.save() return lines def compensate(self, orders): # TODO this compensation is a bit hard to write it propertly # don't forget to think about weighted and num order prices. # Greedy algorithm for maximizing discount (non-deterministic) # Reduce and break orders in donors and receivers donors = [] receivers = [] for order in orders: if order.cancelled_on and order.billed_until > order.cancelled_on: donors.append(order) elif not order.cancelled_on or order.cancelled_on > order.billed_until: receivers.append(order) # Assign weights to every donor-receiver combination weights = [] for donor in donors: for receiver in receivers: if receiver.cancelled_on: if not receiver.cancelled_on or receiver.cancelled_on < donor.billed_until: end = receiver.cancelled_on else: end = donor.billed_until else: end = donor.billed_until ini = donor.billed_until or donor.registered_on if donor.cancelled_on > ini: ini = donor.cancelled_on weight = (end-ini).days weights.append((weight, ini, end, donor, receiver)) # Choose weightest pairs choosen = [] weights.sort(key=lambda n: n[0]) for weight, ini, end, donor, receiver in weigths: if donor not in choosen and receiver not in choosen: choosen += [donor, receiver] donor.billed_until = end donor.save() price = self.get_price()#TODO receiver.__discount_per_compensation =None