diff --git a/TODO.md b/TODO.md index 1f2bc070..1527fcd6 100644 --- a/TODO.md +++ b/TODO.md @@ -102,3 +102,6 @@ at + clock time, midnight, noon- At 3:30 p.m., At 4:01, At noon * transaction.ABORTED -> bill.bad_debt - Issue new transaction when current transaction is ABORTED * underescore *every* private function + + +* create log file at /var/log/orchestra.log and rotate diff --git a/orchestra/apps/bills/admin.py b/orchestra/apps/bills/admin.py index 41ad9216..7a196297 100644 --- a/orchestra/apps/bills/admin.py +++ b/orchestra/apps/bills/admin.py @@ -61,7 +61,7 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin): fieldsets = ( (None, { 'fields': ('number', 'display_total', 'account_link', 'type', - 'is_open', 'display_payment_state', 'is_sent', 'due_on', 'comments'), + 'display_payment_state', 'is_sent', 'due_on', 'comments'), }), (_("Raw"), { 'classes': ('collapse',), @@ -71,7 +71,7 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin): actions = [download_bills, close_bills, send_bills] change_view_actions = [view_bill, download_bills, send_bills, close_bills] change_readonly_fields = ('account_link', 'type', 'is_open') - readonly_fields = ('number', 'display_total', 'display_payment_state') + readonly_fields = ('number', 'display_total', 'is_sent', 'display_payment_state') inlines = [BillLineInline] created_on_display = admin_date('created_on') diff --git a/orchestra/apps/bills/migrations/0007_auto_20140918_1454.py b/orchestra/apps/bills/migrations/0007_auto_20140918_1454.py new file mode 100644 index 00000000..43dff9f6 --- /dev/null +++ b/orchestra/apps/bills/migrations/0007_auto_20140918_1454.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('bills', '0006_auto_20140911_1238'), + ] + + operations = [ + migrations.RemoveField( + model_name='bill', + name='status', + ), + migrations.RemoveField( + model_name='billline', + name='amount', + ), + migrations.RemoveField( + model_name='billline', + name='total', + ), + migrations.AddField( + model_name='bill', + name='is_open', + field=models.BooleanField(default=True, verbose_name='is open'), + preserve_default=True, + ), + migrations.AddField( + model_name='bill', + name='is_sent', + field=models.BooleanField(default=False, verbose_name='is sent'), + preserve_default=True, + ), + migrations.AddField( + model_name='billline', + name='quantity', + field=models.DecimalField(default=10, verbose_name='quantity', max_digits=12, decimal_places=2), + preserve_default=False, + ), + migrations.AddField( + model_name='billline', + name='subtotal', + field=models.DecimalField(default=20, verbose_name='subtotal', max_digits=12, decimal_places=2), + preserve_default=False, + ), + ] diff --git a/orchestra/apps/bills/models.py b/orchestra/apps/bills/models.py index 6020ffa7..0bf3b2b8 100644 --- a/orchestra/apps/bills/models.py +++ b/orchestra/apps/bills/models.py @@ -51,6 +51,7 @@ class Bill(models.Model): type = models.CharField(_("type"), max_length=16, choices=TYPES) created_on = models.DateTimeField(_("created on"), auto_now_add=True) closed_on = models.DateTimeField(_("closed on"), blank=True, null=True) + # TODO rename to is_closed is_open = models.BooleanField(_("is open"), default=True) is_sent = models.BooleanField(_("is sent"), default=False) due_on = models.DateField(_("due on"), null=True, blank=True) @@ -130,7 +131,8 @@ class Bill(models.Model): self.due_on = self.get_due_date(payment=payment) self.total = self.get_total() self.html = self.render(payment=payment) - self.transactions.create(bill=self, source=payment, amount=self.total) + if self.get_type() != 'PROFORMA': + self.transactions.create(bill=self, source=payment, amount=self.total) self.closed_on = timezone.now() self.is_open = False self.is_sent = False diff --git a/orchestra/apps/orders/actions.py b/orchestra/apps/orders/actions.py index 9f1a2aa0..d1317aea 100644 --- a/orchestra/apps/orders/actions.py +++ b/orchestra/apps/orders/actions.py @@ -39,7 +39,7 @@ class BillSelectedOrders(object): billing_point=form.cleaned_data['billing_point'], fixed_point=form.cleaned_data['fixed_point'], is_proforma=form.cleaned_data['is_proforma'], - create_new_open=form.cleaned_data['create_new_open'], + new_open=form.cleaned_data['new_open'], ) if int(request.POST.get('step')) != 3: return self.select_related(request) diff --git a/orchestra/apps/orders/billing.py b/orchestra/apps/orders/billing.py index cb52235b..343af520 100644 --- a/orchestra/apps/orders/billing.py +++ b/orchestra/apps/orders/billing.py @@ -9,7 +9,7 @@ class BillsBackend(object): def create_bills(self, account, lines, **options): bill = None bills = [] - create_new = options.get('create_new_open', False) + create_new = options.get('new_open', False) is_proforma = options.get('is_proforma', False) for line in lines: service = line.order.service @@ -19,16 +19,14 @@ class BillsBackend(object): if create_new: bill = ProForma.objects.create(account=account) else: - bill, __ = ProForma.objects.get_or_create(account=account, - status=ProForma.OPEN) + bill, __ = ProForma.objects.get_or_create(account=account, is_open=True) elif service.is_fee: bill = Fee.objects.create(account=account) else: if create_new: bill = Invoice.objects.create(account=account) else: - bill, __ = Invoice.objects.get_or_create(account=account, - status=Invoice.OPEN) + bill, __ = Invoice.objects.get_or_create(account=account, is_open=True) bills.append(bill) # Create bill line billine = bill.lines.create( diff --git a/orchestra/apps/orders/forms.py b/orchestra/apps/orders/forms.py index 1e573d4a..8e98fb5b 100644 --- a/orchestra/apps/orders/forms.py +++ b/orchestra/apps/orders/forms.py @@ -21,7 +21,7 @@ class BillSelectedOptionsForm(AdminFormMixin, forms.Form): is_proforma = forms.BooleanField(initial=False, required=False, label=_("Pro-forma, billing simulation"), help_text=_("O.")) - create_new_open = forms.BooleanField(initial=False, required=False, + new_open = forms.BooleanField(initial=False, required=False, label=_("Create a new open bill"), help_text=_("Deisgnates whether you want to put this orders on a new " "open bill, or allow to reuse an existing one.")) diff --git a/orchestra/apps/orders/models.py b/orchestra/apps/orders/models.py index 386e85b8..38ba1255 100644 --- a/orchestra/apps/orders/models.py +++ b/orchestra/apps/orders/models.py @@ -1,3 +1,4 @@ +import logging import sys from django.db import models @@ -22,10 +23,14 @@ from . import helpers, settings from .handlers import ServiceHandler +logger = logging.getLogger(__name__) + + class OrderQuerySet(models.QuerySet): group_by = queryset.group_by def bill(self, **options): + # TODO classmethod? bills = [] bill_backend = Order.get_bill_backend() qs = self.select_related('account', 'service') @@ -41,13 +46,17 @@ class OrderQuerySet(models.QuerySet): bills += [(account, bill_lines)] return bills - def filter_givers(self, ini, end): - return self.filter( - cancelled_on__isnull=False, billed_until__isnull=False, - cancelled_on__lte=F('billed_until'), billed_until__gt=ini, - registered_on__lt=end) + def givers(self, ini, end): + return self.cancelled_and_billed().filter(billed_until__gt=ini, registered_on__lt=end) - def filter_pricing_orders(self, ini, end): + def cancelled_and_billed(self, exclude=False): + qs = dict(cancelled_on__isnull=False, billed_until__isnull=False, + cancelled_on__lte=F('billed_until')) + if exclude: + return self.exclude(**qs) + return self.filter(**qs) + + def pricing_orders(self, ini, end): return self.filter(billed_until__isnull=False, billed_until__gt=ini, registered_on__lt=end) @@ -86,18 +95,6 @@ class Order(models.Model): def __unicode__(self): return str(self.service) - def update(self): - instance = self.content_object - handler = self.service.handler - if handler.metric: - metric = handler.get_metric(instance) - if metric is not None: - MetricStorage.store(self, metric) - description = "{}: {}".format(handler.description, str(instance)) - if self.description != description: - self.description = description - self.save() - @classmethod def update_orders(cls, instance): Service = get_model(*settings.ORDERS_SERVICE_MODEL.split('.')) @@ -111,6 +108,7 @@ class Order(models.Model): continue order = cls.objects.create(content_object=instance, service=service, account_id=account_id) + logger.info("CREATED new order id: {id}".format(id=order.id)) else: order = orders.get() order.update() @@ -121,9 +119,24 @@ class Order(models.Model): def get_bill_backend(cls): return import_class(settings.ORDERS_BILLING_BACKEND)() + def update(self): + instance = self.content_object + handler = self.service.handler + if handler.metric: + metric = handler.get_metric(instance) + if metric is not None: + MetricStorage.store(self, metric) + description = "{}: {}".format(handler.description, str(instance)) + logger.info("UPDATED order id: {id} description:{description}".format( + id=self.id, description=description)) + if self.description != description: + self.description = description + self.save() + def cancel(self): self.cancelled_on = timezone.now() self.save() + logger.info("CANCELLED order id: {id}".format(id=self.id)) def get_metric(self, ini, end): return MetricStorage.get(self, ini, end) @@ -162,30 +175,31 @@ class MetricStorage(models.Model): return 0 -# TODO If this happens to be very costly then, consider an additional -# implementation when runnning within a request/Response cycle, more efficient :) -@receiver(pre_delete, dispatch_uid="orders.cancel_orders") +_excluded_models = (MetricStorage, LogEntry, Order, ContentType, MigrationRecorder.Migration) + +@receiver(post_delete, dispatch_uid="orders.cancel_orders") def cancel_orders(sender, **kwargs): - if sender in services: + if sender not in _excluded_models: instance = kwargs['instance'] - for order in Order.objects.by_object(instance).active(): - order.cancel() + if hasattr(instance, 'account'): + for order in Order.objects.by_object(instance).active(): + order.cancel() + else: + related = helpers.get_related_objects(instance) + if related and related != instance: + Order.update_orders(related) @receiver(post_save, dispatch_uid="orders.update_orders") -@receiver(post_delete, dispatch_uid="orders.update_orders_post_delete") def update_orders(sender, **kwargs): - exclude = ( - MetricStorage, LogEntry, Order, ContentType, MigrationRecorder.Migration - ) - if sender not in exclude: + if sender not in _excluded_models: instance = kwargs['instance'] - if instance.pk: - # post_save + if hasattr(instance, 'account'): Order.update_orders(instance) - related = helpers.get_related_objects(instance) - if related: - Order.update_orders(related) + else: + related = helpers.get_related_objects(instance) + if related and related != instance: + Order.update_orders(related) accounts.register(Order) diff --git a/orchestra/apps/orders/tests/functional_tests/tests.py b/orchestra/apps/orders/tests/functional_tests/tests.py index b06c71a5..bc8a6c52 100644 --- a/orchestra/apps/orders/tests/functional_tests/tests.py +++ b/orchestra/apps/orders/tests/functional_tests/tests.py @@ -96,13 +96,16 @@ class BillingTests(BaseTestCase): account = self.create_account() service = self.create_ftp_service() user = self.create_ftp(account=account) - bp = timezone.now().date() + relativedelta.relativedelta(years=2) - bills = service.orders.bill(billing_point=bp, fixed_point=True) + first_bp = timezone.now().date() + relativedelta.relativedelta(years=2) + bills = service.orders.bill(billing_point=first_bp, fixed_point=True) user.delete() user = self.create_ftp(account=account) bp = timezone.now().date() + relativedelta.relativedelta(years=1) - bills = service.orders.bill(billing_point=bp, fixed_point=True) - for line in bills[0].lines.all(): - print line - print line.sublines.all() - # TODO asserts + bills = service.orders.bill(billing_point=bp, fixed_point=True, new_open=True) + discount = bills[0].lines.order_by('id')[0].sublines.get() + self.assertEqual(decimal.Decimal(-20), discount.total) + order = service.orders.order_by('id').first() + self.assertEqual(order.cancelled_on, order.billed_until) + order = service.orders.order_by('-id').first() + self.assertEqual(first_bp, order.billed_until) + self.assertEqual(decimal.Decimal(0), bills[0].get_total()) diff --git a/orchestra/apps/payments/admin.py b/orchestra/apps/payments/admin.py index 490c9d46..fe883f77 100644 --- a/orchestra/apps/payments/admin.py +++ b/orchestra/apps/payments/admin.py @@ -127,12 +127,14 @@ class TransactionAdmin(ChangeViewActionsMixin, AccountAdminMixin, admin.ModelAdm actions = super(TransactionAdmin, self).get_change_view_actions() exclude = [] if obj: + if obj.state == Transaction.WAITTING_PROCESSING: + exclude = ['mark_as_executed', 'mark_as_secured', 'mark_as_rejected'] + elif obj.state == Transaction.WAITTING_EXECUTION: + exclude = ['process_transactions', 'mark_as_secured', 'mark_as_rejected'] if obj.state == Transaction.EXECUTED: - exclude.append('mark_as_executed') - elif obj.state == Transaction.REJECTED: - exclude.append('mark_as_rejected') - elif obj.state == Transaction.SECURED: - exclude.append('mark_as_secured') + exclude = ['process_transactions', 'mark_as_executed'] + elif obj.state in [Transaction.REJECTED, Transaction.SECURED]: + return [] return [action for action in actions if action.__name__ not in exclude] diff --git a/orchestra/apps/payments/models.py b/orchestra/apps/payments/models.py index 77816028..8cb0d92b 100644 --- a/orchestra/apps/payments/models.py +++ b/orchestra/apps/payments/models.py @@ -118,18 +118,22 @@ class Transaction(models.Model): raise ValidationError(_("New transactions can not be allocated for this bill")) def mark_as_processed(self): + assert self.state == self.WAITTING_PROCESSING self.state = self.WAITTING_EXECUTION self.save() def mark_as_executed(self): + assert self.state == self.WAITTING_EXECUTION self.state = self.EXECUTED self.save() def mark_as_secured(self): + assert self.state == self.EXECUTED self.state = self.SECURED self.save() def mark_as_rejected(self): + assert self.state == self.EXECUTED self.state = self.REJECTED self.save() diff --git a/orchestra/apps/services/handlers.py b/orchestra/apps/services/handlers.py index 1f968b18..46bd67bc 100644 --- a/orchestra/apps/services/handlers.py +++ b/orchestra/apps/services/handlers.py @@ -154,12 +154,12 @@ class ServiceHandler(plugins.Plugin): for dtype, dprice in discounts: self.generate_discount(line, dtype, dprice) discounted += dprice - subtotal -= discounted + subtotal += discounted if subtotal > price: self.generate_discount(line, 'volume', price-subtotal) return line - - def compensate(self, givers, receivers, commit=True): + + def assign_compensations(self, givers, receivers, commit=True): compensations = [] for order in givers: if order.billed_until and order.cancelled_on and order.cancelled_on < order.billed_until: @@ -170,17 +170,43 @@ class ServiceHandler(plugins.Plugin): # receiver ini = order.billed_until or order.registered_on end = order.cancelled_on or datetime.date.max - order_interval = helpers.Interval(ini, order.new_billed_until) - compensations, used_compensations = helpers.compensate(order_interval, compensations) + interval = helpers.Interval(ini, end) + compensations, used_compensations = helpers.compensate(interval, compensations) order._compensations = used_compensations for comp in used_compensations: - comp.order.new_billed_until = min(comp.order.billed_until, comp.end) + # 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: for order in givers: if hasattr(order, 'new_billed_until'): order.billed_until = order.new_billed_until order.save() + def apply_compensations(self, order, only_beyond=False): + dsize = 0 + discounts = () + ini = order.billed_until or order.registered_on + end = order.new_billed_until + beyond = end + cend = None + for comp in getattr(order, '_compensations', []): + intersect = comp.intersect(helpers.Interval(ini=ini, end=end)) + if intersect: + cini, cend = intersect.ini, intersect.end + if comp.end > beyond: + cend = comp.end + if only_beyond: + cini = beyond + elif not only_beyond: + continue + dsize += self.get_price_size(cini, cend) + # Extend billing point a little bit to benefit from a substantial discount + elif comp.end > beyond and (comp.end-comp.ini).days > 3*(comp.ini-beyond).days: + cend = comp.end + dsize += self.get_price_size(comp.ini, cend) + return dsize, cend + def get_register_or_renew_events(self, porders, ini, end): # TODO count intermediat billing points too counter = 0 @@ -207,6 +233,7 @@ class ServiceHandler(plugins.Plugin): for position, order in enumerate(orders): csize = 0 compensations = getattr(order, '_compensations', []) + # Compensations < new_billed_until for comp in compensations: intersect = comp.intersect(interval) if intersect: @@ -223,8 +250,15 @@ class ServiceHandler(plugins.Plugin): for order, prices in priced.iteritems(): # Generate lines and discounts from order.nominal_price price, cprice = prices + # Compensations > new_billed_until + dsize, new_end = self.apply_compensations(order, only_beyond=True) + cprice += dsize*price if cprice: - discounts = (('compensation', cprice),) + discounts = (('compensation', -cprice),) + if new_end: + size = self.get_price_size(order.new_billed_until, new_end) + price += price*size + order.new_billed_until = new_end line = self.generate_line(order, price, size, ini, end, discounts=discounts) lines.append(line) if commit: @@ -232,7 +266,7 @@ class ServiceHandler(plugins.Plugin): order.save() return lines - def bill_registered_or_renew_events(self, account, porders, rates, ini, end, commit=True): + def bill_registered_or_renew_events(self, account, porders, rates, commit=True): # Before registration lines = [] perido = self.get_pricing_period() @@ -242,16 +276,24 @@ class ServiceHandler(plugins.Plugin): rdelta = relativedelta.relativedelta(years=1) elif period == self.NEVER: raise NotImplementedError("Rates with no pricing period?") - ini -= rdelta for position, order in enumerate(porders): if hasattr(order, 'new_billed_until'): - cend = order.billed_until or order.registered_on - cini = cend - rdelta - metric = self.get_register_or_renew_events(porders, cini, cend) - size = self.get_price_size(ini, end) + pend = order.billed_until or order.registered_on + pini = pend - rdelta + metric = self.get_register_or_renew_events(porders, pini, pend) price = self.get_price(account, metric, position=position, rates=rates) + ini = order.billed_until or order.registered_on + end = order.new_billed_until + discounts = () + dsize, new_end = self.apply_compensations(order) + if dsize: + discounts=(('compensation', -dsize*price),) + if new_end: + order.new_billed_until = new_end + end = new_end + size = self.get_price_size(ini, end) price = price * size - line = self.generate_line(order, price, size, ini, end) + line = self.generate_line(order, price, size, ini, end, discounts=discounts) lines.append(line) if commit: order.billed_until = order.new_billed_until @@ -262,38 +304,47 @@ 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 - bp = None - lines = [] commit = options.get('commit', True) + + # boundary lookup and exclude cancelled and billed + orders_ = [] + bp = None ini = datetime.date.max end = datetime.date.min - # boundary lookup for order in orders: cini = order.registered_on if order.billed_until: + # exclude cancelled and billed + if self.on_cancel != self.REFOUND: + if order.cancelled_on and order.billed_until > order.cancelled_on: + continue 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) + orders_.append(order) + orders = orders_ + + # Compensation related_orders = account.orders.filter(service=self.service) if self.on_cancel == self.DISCOUNT: # Get orders pending for compensation - givers = list(related_orders.filter_givers(ini, end)) - print givers + 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.compensate(givers, orders, commit=commit) + self.assign_compensations(givers, orders, commit=commit) rates = self.get_rates(account) if rates: - porders = related_orders.filter_pricing_orders(ini, end) + 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: liens = self.bill_concurrent_orders(account, porders, rates, ini, end, commit=commit) else: - lines = self.bill_registered_or_renew_events(account, porders, rates, ini, end, commit=commit) + # TODO compensation in this case? + lines = self.bill_registered_or_renew_events(account, porders, rates, commit=commit) else: lines = [] price = self.nominal_price @@ -301,9 +352,15 @@ class ServiceHandler(plugins.Plugin): for order in orders: ini = order.billed_until or order.registered_on end = order.new_billed_until + discounts = () + dsize, new_end = self.apply_compensations(order) + if dsize: + discounts=(('compensation', -dsize*price),) + if new_end: + order.new_billed_until = new_end + end = new_end size = self.get_price_size(ini, end) - order.nominal_price = price * size - line = self.generate_line(order, price*size, size, ini, end) + line = self.generate_line(order, price*size, size, ini, end, discounts=discounts) lines.append(line) if commit: order.billed_until = order.new_billed_until @@ -311,6 +368,7 @@ class ServiceHandler(plugins.Plugin): return lines def bill_with_metric(self, orders, account, **options): + # TODO filter out orders with cancelled_on < billed_until ? lines = [] commit = options.get('commit', True) for order in orders: @@ -342,7 +400,6 @@ class ServiceHandler(plugins.Plugin): return lines def generate_bill_lines(self, orders, account, **options): - # TODO filter out orders with cancelled_on < billed_until ? if not self.metric: lines = self.bill_with_orders(orders, account, **options) else: diff --git a/orchestra/apps/services/helpers.py b/orchestra/apps/services/helpers.py index 46dd8f88..71f35298 100644 --- a/orchestra/apps/services/helpers.py +++ b/orchestra/apps/services/helpers.py @@ -1,6 +1,3 @@ -from django.utils import timezone - - def get_chunks(porders, ini, end, ix=0): if ix >= len(porders): return [[ini, end, []]] @@ -57,8 +54,10 @@ class Interval(object): return remaining def __repr__(self): - now = timezone.now() - return "Start: %s End: %s" % ((self.ini-now).days, (self.end-now).days) + return "Start: {ini} End: {end}".format( + ini=self.ini.strftime('%Y-%-m-%-d'), + end=self.end.strftime('%Y-%-m-%-d') + ) def intersect(self, other, remaining_self=None, remaining_other=None): if remaining_self is not None: diff --git a/orchestra/apps/services/models.py b/orchestra/apps/services/models.py index ed8da1d2..52138268 100644 --- a/orchestra/apps/services/models.py +++ b/orchestra/apps/services/models.py @@ -172,6 +172,7 @@ class Service(models.Model): choices=( (NOTHING, _("Nothing")), (DISCOUNT, _("Discount")), + (REFOUND, _("Refound")), ), default=DISCOUNT) payment_style = models.CharField(_("payment style"), max_length=16, diff --git a/orchestra/apps/users/models.py b/orchestra/apps/users/models.py index ea301ae8..71aa42c6 100644 --- a/orchestra/apps/users/models.py +++ b/orchestra/apps/users/models.py @@ -38,7 +38,8 @@ class User(auth.AbstractBaseUser): @property def is_main(self): - return self.account.user == self + # TODO chicken and egg + return not self.account.user_id or self.account.user == self def get_full_name(self): full_name = '%s %s' % (self.first_name, self.last_name)