diff --git a/TODO.md b/TODO.md index b7818ad0..11998e8a 100644 --- a/TODO.md +++ b/TODO.md @@ -106,3 +106,6 @@ at + clock time, midnight, noon- At 3:30 p.m., At 4:01, At noon @property def register_on(self): return order.register_at.date() + + +* latest by 'id' *always* diff --git a/orchestra/apps/orders/models.py b/orchestra/apps/orders/models.py index 73f2ad04..7e110fe6 100644 --- a/orchestra/apps/orders/models.py +++ b/orchestra/apps/orders/models.py @@ -141,14 +141,15 @@ class Order(models.Model): self.save() logger.info("CANCELLED order id: {id}".format(id=self.id)) - def get_metric(self, ini, end, changes=False): - if changes: + def get_metric(self, *args, **kwargs): + if kwargs.pop('changes', False): + ini, end = args result = [] prev = None - for metric in self.metrics.filter(created_on__lt=end).order_by('created_on'): - created = metric.created_on.date() + for metric in self.metrics.filter(created_on__lt=end).order_by('id'): + created = metric.created_on if created > ini: - cini = prev.created_on.date() + cini = prev.created_on if not result: cini = ini result.append((cini, created, prev.value)) @@ -156,8 +157,20 @@ class Order(models.Model): if created < end: result.append((created, end, metric.value)) return result - try: + if kwargs: + raise AttributeError + if len(args) == 2: + ini, end = args metrics = self.metrics.filter(updated_on__lt=end, updated_on__gte=ini) + elif len(args) == 1: + date = args[0] + metrics = self.metrics.filter(updated_on__year=date.year, + updated_on__month=date.month, updated_on__day=date.day) + elif not args: + return self.metrics.latest('updated_on').value + else: + raise AttributeError + try: return metrics.latest('updated_on').value except MetricStorage.DoesNotExist: return decimal.Decimal(0) @@ -166,11 +179,11 @@ class Order(models.Model): class MetricStorage(models.Model): order = models.ForeignKey(Order, verbose_name=_("order"), related_name='metrics') value = models.DecimalField(_("value"), max_digits=16, decimal_places=2) - created_on = models.DateTimeField(_("created"), auto_now_add=True) + created_on = models.DateField(_("created"), auto_now_add=True) updated_on = models.DateTimeField(_("updated")) class Meta: - get_latest_by = 'created_on' + get_latest_by = 'id' def __unicode__(self): return unicode(self.order) diff --git a/orchestra/apps/orders/tests/functional_tests/tests.py b/orchestra/apps/orders/tests/functional_tests/tests.py index 26c17cfe..225deca5 100644 --- a/orchestra/apps/orders/tests/functional_tests/tests.py +++ b/orchestra/apps/orders/tests/functional_tests/tests.py @@ -38,7 +38,7 @@ class FTPBillingTest(BaseBillingTest): metric='', pricing_period=Service.NEVER, rate_algorithm=Service.STEP_PRICE, - on_cancel=Service.DISCOUNT, + on_cancel=Service.COMPENSATE, payment_style=Service.PREPAY, tax=0, nominal_price=10, @@ -269,7 +269,6 @@ class TrafficBillingTest(BaseBillingTest): return service def create_traffic_resource(self): - from orchestra.apps.resources.models import Resource self.resource = Resource.objects.create( name='traffic', content_type=ContentType.objects.get_for_model(Account), @@ -341,7 +340,7 @@ class MailboxBillingTest(BaseBillingTest): metric='', pricing_period=Service.NEVER, rate_algorithm=Service.STEP_PRICE, - on_cancel=Service.DISCOUNT, + on_cancel=Service.COMPENSATE, payment_style=Service.PREPAY, tax=0, nominal_price=10 @@ -467,15 +466,15 @@ class JobBillingTest(BaseBillingTest): is_fee=False, metric='miscellaneous.amount', pricing_period=Service.BILLING_PERIOD, - rate_algorithm=Service.STEP_PRICE, + rate_algorithm=Service.MATCH_PRICE, on_cancel=Service.NOTHING, payment_style=Service.POSTPAY, tax=0, - nominal_price=10 + nominal_price=20 ) plan = Plan.objects.create(is_default=True, name='Default') - service.rates.create(plan=plan, quantity=1, price=0) - service.rates.create(plan=plan, quantity=11, price=10) + service.rates.create(plan=plan, quantity=1, price=20) + service.rates.create(plan=plan, quantity=10, price=15) return service def create_job(self, amount, account=None): @@ -488,9 +487,14 @@ class JobBillingTest(BaseBillingTest): def test_job(self): service = self.create_job_service() account = self.create_account() - job = self.create_job(10, account=account) - print service.orders.all() - print service.orders.bill()[0].get_total() + + job = self.create_job(5, account=account) + bill = service.orders.bill()[0] + self.assertEqual(5*20, bill.get_total()) + + job = self.create_job(100, account=account) + bill = service.orders.bill(new_open=True)[0] + self.assertEqual(100*15, bill.get_total()) class PlanBillingTest(BaseBillingTest): diff --git a/orchestra/apps/services/handlers.py b/orchestra/apps/services/handlers.py index 48e525c1..4f477351 100644 --- a/orchestra/apps/services/handlers.py +++ b/orchestra/apps/services/handlers.py @@ -124,14 +124,13 @@ class ServiceHandler(plugins.Plugin): day = ini.day month = ini.month period = self.get_pricing_period() + rdelta = self.get_pricing_rdelta() if period == self.MONTHLY: ini = datetime.datetime(year=ini.year, month=ini.month, day=day, tzinfo=timezone.get_current_timezone()).date() - rdelta = relativedelta.relativedelta(months=1) elif period == self.ANUAL: ini = datetime.datetime(year=ini.year, month=month, day=day, tzinfo=timezone.get_current_timezone()).date() - rdelta = relativedelta.relativedelta(years=1) elif period == self.NEVER: yield ini, end raise StopIteration @@ -144,6 +143,15 @@ class ServiceHandler(plugins.Plugin): break ini = next + def get_pricing_rdelta(self): + period = self.get_pricing_period() + if period == self.MONTHLY: + return relativedelta.relativedelta(months=1) + elif period == self.ANUAL: + return relativedelta.relativedelta(years=1) + elif period == self.NEVER: + return None + def generate_discount(self, line, dtype, price): line.discounts.append(AttributeDict(**{ 'type': dtype, @@ -162,6 +170,7 @@ class ServiceHandler(plugins.Plugin): computed = kwargs.pop('computed', False) if kwargs: raise AttributeError + size = self.get_price_size(ini, end) if not computed: price = price * size @@ -184,7 +193,7 @@ class ServiceHandler(plugins.Plugin): self.generate_discount(line, 'volume', price-subtotal) return line - def assign_compensations(self, givers, receivers, commit=True): + def assign_compensations(self, givers, receivers, **options): compensations = [] for order in givers: if order.billed_until and order.cancelled_on and order.cancelled_on < order.billed_until: @@ -202,7 +211,7 @@ class ServiceHandler(plugins.Plugin): # TODO get min right comp.order.new_billed_until = min(comp.order.billed_until, comp.ini, getattr(comp.order, 'new_billed_until', datetime.date.max)) - if commit: + if options.get('commit', True): for order in givers: if hasattr(order, 'new_billed_until'): order.billed_until = order.new_billed_until @@ -246,7 +255,7 @@ class ServiceHandler(plugins.Plugin): counter += 1 return counter - def bill_concurrent_orders(self, account, porders, rates, ini, end, commit=True): + def bill_concurrent_orders(self, account, porders, rates, ini, end): # Concurrent # Get pricing orders priced = {} @@ -286,21 +295,14 @@ class ServiceHandler(plugins.Plugin): order.new_billed_until = new_end line = self.generate_line(order, price, ini, new_end or end, discounts=discounts, computed=True) lines.append(line) - if commit: - order.billed_until = order.new_billed_until - order.save() return lines - def bill_registered_or_renew_events(self, account, porders, rates, commit=True): + def bill_registered_or_renew_events(self, account, porders, rates): # Before registration lines = [] - 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: - raise NotImplementedError("Rates with no pricing period?") + rdelta = self.get_pricing_rdelta() + if not rdelta: + raise NotImplementedError for position, order in enumerate(porders, start=1): if hasattr(order, 'new_billed_until'): pend = order.billed_until or order.registered_on @@ -319,9 +321,6 @@ class ServiceHandler(plugins.Plugin): size = self.get_price_size(ini, end) line = self.generate_line(order, price, ini, end, discounts=discounts) lines.append(line) - if commit: - order.billed_until = order.new_billed_until - order.save() return lines def bill_with_orders(self, orders, account, **options): @@ -329,14 +328,11 @@ class ServiceHandler(plugins.Plugin): # date(2011, 1, 1) is equivalent to datetime(2011, 1, 1, 0, 0, 0) # In most cases: # ini >= registered_date, end < registered_date - commit = options.get('commit', True) - # boundary lookup and exclude cancelled and billed orders_ = [] bp = None ini = datetime.date.max end = datetime.date.min - # TODO compensation with one time billing? for order in orders: cini = order.registered_on if order.billed_until: @@ -354,24 +350,33 @@ class ServiceHandler(plugins.Plugin): # Compensation related_orders = account.orders.filter(service=self.service) - if self.on_cancel == self.DISCOUNT: + if self.on_cancel == self.COMPENSATE: # Get orders pending for compensation givers = list(related_orders.givers(ini, end)) givers.sort(cmp=helpers.cmp_billed_until_or_registered_on) orders.sort(cmp=helpers.cmp_billed_until_or_registered_on) - self.assign_compensations(givers, orders, commit=commit) + self.assign_compensations(givers, orders, **options) rates = self.get_rates(account) - if rates: + has_billing_period = self.billing_period != self.NEVER + has_pricing_period = self.get_pricing_period() != self.NEVER + if rates and (has_billing_period or has_pricing_period): + concurrent = has_billing_period and not has_pricing_period + if not concurrent: + rdelta = self.get_pricing_rdelta() + ini -= rdelta porders = related_orders.pricing_orders(ini, end) porders = list(set(orders).union(set(porders))) porders.sort(cmp=helpers.cmp_billed_until_or_registered_on) - if self.billing_period != self.NEVER and self.get_pricing_period() == self.NEVER: - lines = self.bill_concurrent_orders(account, porders, rates, ini, end, commit=commit) + if concurrent: + # Periodic billing with no pricing period + lines = self.bill_concurrent_orders(account, porders, rates, ini, end) else: # TODO compensation in this case? - lines = self.bill_registered_or_renew_events(account, porders, rates, commit=commit) + # Periodic and one-time billing with pricing period + lines = self.bill_registered_or_renew_events(account, porders, rates) else: + # No rates optimization or one-time billing without pricing period lines = [] price = self.nominal_price # Calculate nominal price @@ -387,47 +392,45 @@ class ServiceHandler(plugins.Plugin): end = new_end line = self.generate_line(order, price, ini, end, discounts=discounts) lines.append(line) - if commit: - order.billed_until = order.new_billed_until - order.save() return lines def bill_with_metric(self, orders, account, **options): lines = [] - commit = options.get('commit', True) bp = None for order in orders: - if order.billed_until and order.cancelled_on >= order.billed_until: + if order.billed_until and order.cancelled_on and order.cancelled_on >= order.billed_until: continue - bp = self.get_billing_point(order, bp=bp, **options) - ini = order.billed_until or order.registered_on - if bp <= ini: - # TODO except one time service - continue - order.new_billed_until = bp if self.billing_period != self.NEVER: + bp = self.get_billing_point(order, bp=bp, **options) + ini = order.billed_until or order.registered_on + # Periodic billing + if bp <= ini: + continue + order.new_billed_until = bp if self.get_pricing_period() == self.NEVER: - # Changes + # Changes (Mailbox disk-like) for ini, end, metric in order.get_metric(ini, bp, changes=True): price = self.get_price(order, metric) lines.append(self.generate_line(order, price, ini, end, metric=metric)) else: - # pricing_slots + # pricing_slots (Traffic-like) for ini, end in self.get_pricing_slots(ini, bp): metric = order.get_metric(ini, end) price = self.get_price(order, metric) lines.append(self.generate_line(order, price, ini, end, metric=metric)) else: + # One-time billing + if order.billed_until: + continue + date = order.registered_on + order.new_billed_until = date if self.get_pricing_period() == self.NEVER: - # get metric - metric = order.get_metric(ini, end) + # get metric (Job-like) + metric = order.get_metric(date) price = self.get_price(order, metric) - lines.append(self.generate_line(order, price, ini, bp, metric=metric)) + lines.append(self.generate_line(order, price, date, metric=metric)) else: raise NotImplementedError - if commit: - order.billed_until = order.new_billed_until - order.save() return lines def generate_bill_lines(self, orders, account, **options): @@ -437,4 +440,8 @@ class ServiceHandler(plugins.Plugin): lines = self.bill_with_orders(orders, account, **options) else: lines = self.bill_with_metric(orders, account, **options) + if options.get('commit', True): + for line in lines: + line.order.billed_until = line.order.new_billed_until + line.order.save() return lines diff --git a/orchestra/apps/services/models.py b/orchestra/apps/services/models.py index 43b72391..fef937ad 100644 --- a/orchestra/apps/services/models.py +++ b/orchestra/apps/services/models.py @@ -89,6 +89,7 @@ class Service(models.Model): CONCURRENT = 'CONCURRENT' NOTHING = 'NOTHING' DISCOUNT = 'DISCOUNT' + COMPENSATE = 'COMPENSATE' REFOUND = 'REFOUND' PREPAY = 'PREPAY' POSTPAY = 'POSTPAY' @@ -173,6 +174,7 @@ class Service(models.Model): choices=( (NOTHING, _("Nothing")), (DISCOUNT, _("Discount")), + (COMPENSATE, _("Compensat")), (REFOUND, _("Refound")), ), default=DISCOUNT) @@ -266,13 +268,13 @@ class Service(models.Model): """ if rates is None: rates = self.get_rates(account) + if rates: + rates = self.rate_method(rates, metric) if not rates: rates = [{ 'quantity': metric, 'price': self.nominal_price, }] - else: - rates = self.rate_method(rates, metric) counter = 0 if position is None: ant_counter = 0 diff --git a/orchestra/apps/services/rating.py b/orchestra/apps/services/rating.py index 0e9d4e56..20a3792f 100644 --- a/orchestra/apps/services/rating.py +++ b/orchestra/apps/services/rating.py @@ -44,7 +44,6 @@ def _compute(rates, metric): def step_price(rates, metric): # Step price - # TODO allow multiple plans group = [] minimal = (sys.maxint, []) for plan, rates in rates.group_by('plan').iteritems(): @@ -104,18 +103,22 @@ def match_price(rates, metric): selected = False prev = None for rate in rates.distinct(): - if prev and prev.plan != rate.plan: - if not selected and prev.quantity <= metric: - candidates.append(prev) - selected = False - if not selected and rate.quantity > metric: - candidates.append(prev) - selected = True + if prev: + if prev.plan != rate.plan: + if not selected and prev.quantity <= metric: + candidates.append(prev) + selected = False + if not selected and rate.quantity > metric: + if prev.quantity <= metric: + candidates.append(prev) + selected = True prev = rate if not selected and prev.quantity <= metric: candidates.append(prev) candidates.sort(key=lambda r: r.price) - return [AttributeDict(**{ - 'quantity': metric, - 'price': candidates[0].price, - })] + if candidates: + return [AttributeDict(**{ + 'quantity': metric, + 'price': candidates[0].price, + })] + return None