From 4c603bf584225ace531a0f261d74f22427b808d0 Mon Sep 17 00:00:00 2001 From: Marc Date: Fri, 5 Sep 2014 14:27:30 +0000 Subject: [PATCH] Improvements on bills and payment management --- TODO.md | 5 ++ orchestra/admin/decorators.py | 4 +- orchestra/admin/menu.py | 4 +- orchestra/admin/utils.py | 5 +- orchestra/apps/accounts/admin.py | 3 +- orchestra/apps/bills/actions.py | 26 +++++- orchestra/apps/bills/forms.py | 34 ++++++++ .../apps/bills/migrations/0001_initial.py | 2 +- ...yment_source.py => 0002_bill_closed_on.py} | 5 +- orchestra/apps/bills/models.py | 59 ++++++++------ orchestra/apps/bills/settings.py | 1 + .../admin/bills/close_confirmation.html | 4 +- .../templates/bills/microspective-fee.html | 8 +- .../bills/templates/bills/microspective.html | 10 +-- orchestra/apps/orders/backends.py | 18 +++-- orchestra/apps/payments/admin.py | 80 ++++++++++++++++--- orchestra/apps/payments/forms.py | 40 ---------- orchestra/apps/payments/methods/options.py | 5 ++ .../apps/payments/methods/sepadirectdebit.py | 42 +++++----- orchestra/apps/payments/models.py | 27 ++++--- .../payment_source/select_method.html | 28 +++++++ 21 files changed, 279 insertions(+), 131 deletions(-) create mode 100644 orchestra/apps/bills/forms.py rename orchestra/apps/bills/migrations/{0002_bill_payment_source.py => 0002_bill_closed_on.py} (57%) delete mode 100644 orchestra/apps/payments/forms.py create mode 100644 orchestra/apps/payments/templates/admin/payments/payment_source/select_method.html diff --git a/TODO.md b/TODO.md index 2b7b6e70..cfe50bb0 100644 --- a/TODO.md +++ b/TODO.md @@ -81,3 +81,8 @@ at + clock time, midnight, noon- At 3:30 p.m., At 4:01, At noon * Rename pack to plan ? one can have multiple plans? * transaction.process FK? + +* translations + from django.utils import translation + with translation.override('en'): +* Plurals! diff --git a/orchestra/admin/decorators.py b/orchestra/admin/decorators.py index aabbeb87..6554413f 100644 --- a/orchestra/admin/decorators.py +++ b/orchestra/admin/decorators.py @@ -14,10 +14,10 @@ def admin_field(method): kwargs['field'] = args[0] if args else '' kwargs['order'] = kwargs.get('order', kwargs['field']) kwargs['popup'] = kwargs.get('popup', False) - kwargs['description'] = kwargs.get('description', + kwargs['short_description'] = kwargs.get('short_description', kwargs['field'].split('__')[-1].replace('_', ' ').capitalize()) admin_method = partial(method, **kwargs) - admin_method.short_description = kwargs['description'] + admin_method.short_description = kwargs['short_description'] admin_method.allow_tags = True admin_method.admin_order_field = kwargs['order'] return admin_method diff --git a/orchestra/admin/menu.py b/orchestra/admin/menu.py index ac481170..47768d36 100644 --- a/orchestra/admin/menu.py +++ b/orchestra/admin/menu.py @@ -62,8 +62,8 @@ def get_account_items(): if isinstalled('orchestra.apps.payments'): url = reverse('admin:payments_transaction_changelist') childrens.append(items.MenuItem(_("Transactions"), url)) - url = reverse('admin:payments_paymentprocess_changelist') - childrens.append(items.MenuItem(_("Payment processes"), url)) + url = reverse('admin:payments_transactionprocess_changelist') + childrens.append(items.MenuItem(_("Transaction processes"), url)) url = reverse('admin:payments_paymentsource_changelist') childrens.append(items.MenuItem(_("Payment sources"), url)) if isinstalled('orchestra.apps.issues'): diff --git a/orchestra/admin/utils.py b/orchestra/admin/utils.py index 90c9d1ca..038169e2 100644 --- a/orchestra/admin/utils.py +++ b/orchestra/admin/utils.py @@ -93,7 +93,10 @@ def action_to_view(action, modeladmin): @admin_field def admin_link(*args, **kwargs): instance = args[-1] - obj = get_field_value(instance, kwargs['field']) + if kwargs['field'] in ['id', 'pk', '__unicode__']: + obj = instance + else: + obj = get_field_value(instance, kwargs['field']) if not getattr(obj, 'pk', None): return '---' opts = obj._meta diff --git a/orchestra/apps/accounts/admin.py b/orchestra/apps/accounts/admin.py index 9d61bb13..771f0795 100644 --- a/orchestra/apps/accounts/admin.py +++ b/orchestra/apps/accounts/admin.py @@ -104,6 +104,7 @@ class AccountListAdmin(AccountAdmin): ordering = ('user__username',) def select_account(self, instance): + # TODO get query string from request.META['QUERY_STRING'] to preserve filters context = { 'url': '../?account=' + str(instance.pk), 'name': instance.name @@ -262,7 +263,7 @@ class SelectAccountAdminMixin(AccountAdminMixin): context.update(extra_context or {}) return super(AccountAdminMixin, self).add_view(request, form_url=form_url, extra_context=context) - return HttpResponseRedirect('./select-account/') + return HttpResponseRedirect('./select-account/?%s' % request.META['QUERY_STRING']) def save_model(self, request, obj, form, change): """ diff --git a/orchestra/apps/bills/actions.py b/orchestra/apps/bills/actions.py index c2aed2f5..7e25944c 100644 --- a/orchestra/apps/bills/actions.py +++ b/orchestra/apps/bills/actions.py @@ -1,6 +1,7 @@ import StringIO import zipfile +from django.contrib import messages from django.http import HttpResponse from django.utils.translation import ugettext_lazy as _ @@ -34,10 +35,29 @@ view_bill.verbose_name = _("View") view_bill.url_name = 'view' +from django import forms +from django.forms.models import BaseModelFormSet +from django.forms.formsets import formset_factory +from django.forms.models import modelformset_factory +from django.shortcuts import render + +from .forms import SelectPaymentSourceForm + def close_bills(modeladmin, request, queryset): - # TODO confirmation with payment source selection - for bill in queryset: - bill.close() + queryset = queryset.filter(status=queryset.model.OPEN) + if not queryset: + messages.warning(request, _("Selected bills should be in open state")) + return + SelectPaymentSourceFormSet = modelformset_factory(queryset.model, form=SelectPaymentSourceForm, extra=0) + if request.POST.get('action') == 'close_selected_bills': + formset = SelectPaymentSourceFormSet(request.POST, queryset=queryset) + if formset.is_valid(): + for form in formset.forms: + form.save() + messages.success(request, _("Selected bills have been closed")) + return + formset = SelectPaymentSourceFormSet(queryset=queryset) + return render(request, 'admin/bills/close_confirmation.html', {'formset': formset}) close_bills.verbose_name = _("Close") close_bills.url_name = 'close' diff --git a/orchestra/apps/bills/forms.py b/orchestra/apps/bills/forms.py new file mode 100644 index 00000000..b120243f --- /dev/null +++ b/orchestra/apps/bills/forms.py @@ -0,0 +1,34 @@ +from django import forms +from django.utils.translation import ugettext_lazy as _ + + +class SelectPaymentSourceForm(forms.ModelForm): + source = forms.ChoiceField(label=_("Source"), required=False) + + class Meta: + fields = ('number', 'source') + + def __init__(self, *args, **kwargs): + super(SelectPaymentSourceForm, self).__init__(*args, **kwargs) + bill = kwargs.get('instance') + if bill: + sources = bill.account.paymentsources.filter(is_active=True) + recharge = bool(bill.get_total() < 0) + choices = [(None, '-----------')] + for source in sources: + if not recharge or source.method_class().allow_recharge: + choices.append((source.pk, str(source))) + self.fields['source'].choices = choices + + def clean_source(self): + source_id = self.cleaned_data['source'] + if not source_id: + return None + source_model = self.instance.account.paymentsources.model + return source_model.objects.get(id=source_id) + + def save(self, commit=True): + if commit: + source = self.cleaned_data['source'] + self.instance.close(payment=source) + return self.instance diff --git a/orchestra/apps/bills/migrations/0001_initial.py b/orchestra/apps/bills/migrations/0001_initial.py index c86be902..f14e385c 100644 --- a/orchestra/apps/bills/migrations/0001_initial.py +++ b/orchestra/apps/bills/migrations/0001_initial.py @@ -17,7 +17,7 @@ class Migration(migrations.Migration): ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('number', models.CharField(unique=True, max_length=16, verbose_name='number', blank=True)), ('type', models.CharField(max_length=16, verbose_name='type', choices=[(b'INVOICE', 'Invoice'), (b'AMENDMENTINVOICE', 'Amendment invoice'), (b'FEE', 'Fee'), (b'AMENDMENTFEE', 'Amendment Fee'), (b'BUDGET', 'Budget')])), - ('status', models.CharField(default=b'OPEN', max_length=16, verbose_name='status', choices=[(b'OPEN', 'Open'), (b'CLOSED', 'Closed'), (b'SENT', 'Sent'), (b'PAID', 'Paid'), (b'RETURNED', 'Returned'), (b'BAD_DEBT', 'Bad debt')])), + ('status', models.CharField(default=b'OPEN', max_length=16, verbose_name='status', choices=[(b'OPEN', 'Open'), (b'CLOSED', 'Closed'), (b'SENT', 'Sent'), (b'PAID', 'Paid'), (b'BAD_DEBT', 'Bad debt')])), ('created_on', models.DateTimeField(auto_now_add=True, verbose_name='created on')), ('due_on', models.DateField(null=True, verbose_name='due on', blank=True)), ('last_modified_on', models.DateTimeField(auto_now=True, verbose_name='last modified on')), diff --git a/orchestra/apps/bills/migrations/0002_bill_payment_source.py b/orchestra/apps/bills/migrations/0002_bill_closed_on.py similarity index 57% rename from orchestra/apps/bills/migrations/0002_bill_payment_source.py rename to orchestra/apps/bills/migrations/0002_bill_closed_on.py index bfac4dde..5ab0d042 100644 --- a/orchestra/apps/bills/migrations/0002_bill_payment_source.py +++ b/orchestra/apps/bills/migrations/0002_bill_closed_on.py @@ -7,15 +7,14 @@ from django.db import models, migrations class Migration(migrations.Migration): dependencies = [ - ('payments', '__first__'), ('bills', '0001_initial'), ] operations = [ migrations.AddField( model_name='bill', - name='payment_source', - field=models.ForeignKey(blank=True, to='payments.PaymentSource', help_text='Optionally specify a payment source for this bill', null=True, verbose_name='payment source'), + name='closed_on', + field=models.DateTimeField(null=True, verbose_name='closed on', blank=True), preserve_default=True, ), ] diff --git a/orchestra/apps/bills/models.py b/orchestra/apps/bills/models.py index e51cfc13..b40a2059 100644 --- a/orchestra/apps/bills/models.py +++ b/orchestra/apps/bills/models.py @@ -1,4 +1,5 @@ import inspect +from dateutil.relativedelta import relativedelta from django.db import models from django.template import loader, Context @@ -28,14 +29,12 @@ class Bill(models.Model): CLOSED = 'CLOSED' SENT = 'SENT' PAID = 'PAID' - RETURNED = 'RETURNED' BAD_DEBT = 'BAD_DEBT' STATUSES = ( (OPEN, _("Open")), (CLOSED, _("Closed")), (SENT, _("Sent")), (PAID, _("Paid")), - (RETURNED, _("Returned")), (BAD_DEBT, _("Bad debt")), ) @@ -51,13 +50,11 @@ class Bill(models.Model): blank=True) account = models.ForeignKey('accounts.Account', verbose_name=_("account"), related_name='%(class)s') - payment_source = models.ForeignKey('payments.PaymentSource', null=True, - verbose_name=_("payment source"), - help_text=_("Optionally specify a payment source for this bill")) type = models.CharField(_("type"), max_length=16, choices=TYPES) status = models.CharField(_("status"), max_length=16, choices=STATUSES, default=OPEN) created_on = models.DateTimeField(_("created on"), auto_now_add=True) + closed_on = models.DateTimeField(_("closed on"), blank=True, null=True) due_on = models.DateField(_("due on"), null=True, blank=True) last_modified_on = models.DateTimeField(_("last modified on"), auto_now=True) #base = models.DecimalField(max_digits=12, decimal_places=2) @@ -95,27 +92,40 @@ class Bill(models.Model): bill_type = self.get_type() if bill_type == 'BILL': raise TypeError("get_new_number() can not be used on a Bill class") - # Bill number resets every natural year - year = timezone.now().strftime("%Y") - bills = cls.objects.filter(created_on__year=year) - number_length = settings.BILLS_NUMBER_LENGTH prefix = getattr(settings, 'BILLS_%s_NUMBER_PREFIX' % bill_type) if self.status == self.OPEN: prefix = 'O{}'.format(prefix) - bills = bills.filter(status=self.OPEN) - num_bills = bills.order_by('-number').first() or 0 - if num_bills is not 0: - num_bills = int(num_bills.number[-number_length:]) + bills = cls.objects.filter(number__regex=r'^%s[1-9]+' % prefix) + last_number = bills.order_by('-number').values_list('number', flat=True).first() + if last_number is None: + last_number = 0 else: - bills = bills.exclude(status=self.OPEN) - num_bills = bills.count() - zeros = (number_length - len(str(num_bills))) * '0' - number = zeros + str(num_bills + 1) + last_number = int(last_number[len(prefix)+4:]) + number = last_number + 1 + year = timezone.now().strftime("%Y") + number_length = settings.BILLS_NUMBER_LENGTH + zeros = (number_length - len(str(number))) * '0' + number = zeros + str(number) self.number = '{prefix}{year}{number}'.format( prefix=prefix, year=year, number=number) - def close(self): - self.html = self.render() + def get_due_date(self, payment=None): + now = timezone.now() + if payment: + return now + payment.get_due_delta() + return now + relativedelta(months=1) + + def close(self, payment=False): + assert self.status == self.OPEN, "Bill not in Open state" + if payment is False: + payment = self.account.paymentsources.get_default() + if not self.due_on: + self.due_on = self.get_due_date(payment=payment) + self.html = self.render(payment=payment) + self.transactions.create( + bill=self, source=payment, amount=self.get_total() + ) + self.closed_on = timezone.now() self.status = self.CLOSED self.save() @@ -131,13 +141,12 @@ class Bill(models.Model): ('%s.pdf' % self.number, html_to_pdf(self.html), 'application/pdf') ] ) - self.transactions.create( - bill=self, source=self.payment_source, amount=self.get_total() - ) self.status = self.SENT self.save() - def render(self): + def render(self, payment=False): + if payment is False: + payment = self.account.paymentsources.get_default() context = Context({ 'bill': self, 'lines': self.lines.all().prefetch_related('sublines'), @@ -147,8 +156,12 @@ class Bill(models.Model): 'phone': settings.BILLS_SELLER_PHONE, 'website': settings.BILLS_SELLER_WEBSITE, 'email': settings.BILLS_SELLER_EMAIL, + 'bank_account': settings.BILLS_SELLER_BANK_ACCOUNT, }, 'currency': settings.BILLS_CURRENCY, + 'payment': payment and payment.get_bill_context(), + 'default_due_date': self.get_due_date(payment=payment), + 'now': timezone.now(), }) template = getattr(settings, 'BILLS_%s_TEMPLATE' % self.get_type(), settings.BILLS_DEFAULT_TEMPLATE) diff --git a/orchestra/apps/bills/settings.py b/orchestra/apps/bills/settings.py index 81dabbd6..7649405f 100644 --- a/orchestra/apps/bills/settings.py +++ b/orchestra/apps/bills/settings.py @@ -30,6 +30,7 @@ BILLS_SELLER_EMAIL = getattr(settings, 'BILLS_SELLER_EMAIL', 'sales@orchestra.la BILLS_SELLER_WEBSITE = getattr(settings, 'BILLS_SELLER_WEBSITE', 'www.orchestra.lan') +BILLS_SELLER_BANK_ACCOUNT = getattr(settings, 'BILLS_SELLER_BANK_ACCOUNT', '0000 0000 00 00000000 (Orchestra Bank)') BILLS_EMAIL_NOTIFICATION_TEMPLATE = getattr(settings, 'BILLS_EMAIL_NOTIFICATION_TEMPLATE', diff --git a/orchestra/apps/bills/templates/admin/bills/close_confirmation.html b/orchestra/apps/bills/templates/admin/bills/close_confirmation.html index 019c09bb..345f4e63 100644 --- a/orchestra/apps/bills/templates/admin/bills/close_confirmation.html +++ b/orchestra/apps/bills/templates/admin/bills/close_confirmation.html @@ -8,11 +8,9 @@ {% block breadcrumbs %} -TODO {% endblock %} - {% block content %}

Are you sure you want to close selected bills

Once a bill is closed it can not be further modified.

@@ -20,7 +18,7 @@ TODO
{% csrf_token %}
- {{ form.as_admin }} + {{ formset }}
{% for obj in queryset %} diff --git a/orchestra/apps/bills/templates/bills/microspective-fee.html b/orchestra/apps/bills/templates/bills/microspective-fee.html index 90ba630d..87625dab 100644 --- a/orchestra/apps/bills/templates/bills/microspective-fee.html +++ b/orchestra/apps/bills/templates/bills/microspective-fee.html @@ -51,13 +51,13 @@ font-weight: bold; } - #date { clear: left; clear: right; margin-top: 0px; padding-top: 0px; font-weight: bold; + color: #666; } #text { @@ -97,13 +97,13 @@ hr {
Membership Fee
{{ bill.number }}
- {{ bill.created_on | date }}
+ {{ bill.closed_on | default:now | date }}
{{ bill.get_total }} €
- To pay before {{ bill.due_date }}
- on 213.232.322.232.332
+ Due date {{ payment.due_date | default:default_due_date | date }}
+ {% if not payment.message %}On {{ seller_info.bank_account }}{% endif %}
diff --git a/orchestra/apps/bills/templates/bills/microspective.html b/orchestra/apps/bills/templates/bills/microspective.html index 54d96dc0..00dcc9d0 100644 --- a/orchestra/apps/bills/templates/bills/microspective.html +++ b/orchestra/apps/bills/templates/bills/microspective.html @@ -46,7 +46,7 @@
DUE DATE
- {{ bill.due_on|date }} + {{ bill.due_on | default:default_due_date | date }}
TOTAL
@@ -54,7 +54,7 @@
{{ bill.get_type_display.upper }} DATE
- {{ bill.created_on|date }} + {{ bill.closed_on | default:now | date }}
@@ -122,13 +122,13 @@