Improvements on bills and payment management

This commit is contained in:
Marc 2014-09-05 14:27:30 +00:00
parent 13df742284
commit 4c603bf584
21 changed files with 279 additions and 131 deletions

View File

@ -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? * Rename pack to plan ? one can have multiple plans?
* transaction.process FK? * transaction.process FK?
* translations
from django.utils import translation
with translation.override('en'):
* Plurals!

View File

@ -14,10 +14,10 @@ def admin_field(method):
kwargs['field'] = args[0] if args else '' kwargs['field'] = args[0] if args else ''
kwargs['order'] = kwargs.get('order', kwargs['field']) kwargs['order'] = kwargs.get('order', kwargs['field'])
kwargs['popup'] = kwargs.get('popup', False) kwargs['popup'] = kwargs.get('popup', False)
kwargs['description'] = kwargs.get('description', kwargs['short_description'] = kwargs.get('short_description',
kwargs['field'].split('__')[-1].replace('_', ' ').capitalize()) kwargs['field'].split('__')[-1].replace('_', ' ').capitalize())
admin_method = partial(method, **kwargs) 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.allow_tags = True
admin_method.admin_order_field = kwargs['order'] admin_method.admin_order_field = kwargs['order']
return admin_method return admin_method

View File

@ -62,8 +62,8 @@ def get_account_items():
if isinstalled('orchestra.apps.payments'): if isinstalled('orchestra.apps.payments'):
url = reverse('admin:payments_transaction_changelist') url = reverse('admin:payments_transaction_changelist')
childrens.append(items.MenuItem(_("Transactions"), url)) childrens.append(items.MenuItem(_("Transactions"), url))
url = reverse('admin:payments_paymentprocess_changelist') url = reverse('admin:payments_transactionprocess_changelist')
childrens.append(items.MenuItem(_("Payment processes"), url)) childrens.append(items.MenuItem(_("Transaction processes"), url))
url = reverse('admin:payments_paymentsource_changelist') url = reverse('admin:payments_paymentsource_changelist')
childrens.append(items.MenuItem(_("Payment sources"), url)) childrens.append(items.MenuItem(_("Payment sources"), url))
if isinstalled('orchestra.apps.issues'): if isinstalled('orchestra.apps.issues'):

View File

@ -93,7 +93,10 @@ def action_to_view(action, modeladmin):
@admin_field @admin_field
def admin_link(*args, **kwargs): def admin_link(*args, **kwargs):
instance = args[-1] 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): if not getattr(obj, 'pk', None):
return '---' return '---'
opts = obj._meta opts = obj._meta

View File

@ -104,6 +104,7 @@ class AccountListAdmin(AccountAdmin):
ordering = ('user__username',) ordering = ('user__username',)
def select_account(self, instance): def select_account(self, instance):
# TODO get query string from request.META['QUERY_STRING'] to preserve filters
context = { context = {
'url': '../?account=' + str(instance.pk), 'url': '../?account=' + str(instance.pk),
'name': instance.name 'name': instance.name
@ -262,7 +263,7 @@ class SelectAccountAdminMixin(AccountAdminMixin):
context.update(extra_context or {}) context.update(extra_context or {})
return super(AccountAdminMixin, self).add_view(request, return super(AccountAdminMixin, self).add_view(request,
form_url=form_url, extra_context=context) 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): def save_model(self, request, obj, form, change):
""" """

View File

@ -1,6 +1,7 @@
import StringIO import StringIO
import zipfile import zipfile
from django.contrib import messages
from django.http import HttpResponse from django.http import HttpResponse
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -34,10 +35,29 @@ view_bill.verbose_name = _("View")
view_bill.url_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): def close_bills(modeladmin, request, queryset):
# TODO confirmation with payment source selection queryset = queryset.filter(status=queryset.model.OPEN)
for bill in queryset: if not queryset:
bill.close() 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.verbose_name = _("Close")
close_bills.url_name = 'close' close_bills.url_name = 'close'

View File

@ -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

View File

@ -17,7 +17,7 @@ class Migration(migrations.Migration):
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('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)), ('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')])), ('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')), ('created_on', models.DateTimeField(auto_now_add=True, verbose_name='created on')),
('due_on', models.DateField(null=True, verbose_name='due on', blank=True)), ('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')), ('last_modified_on', models.DateTimeField(auto_now=True, verbose_name='last modified on')),

View File

@ -7,15 +7,14 @@ from django.db import models, migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('payments', '__first__'),
('bills', '0001_initial'), ('bills', '0001_initial'),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='bill', model_name='bill',
name='payment_source', name='closed_on',
field=models.ForeignKey(blank=True, to='payments.PaymentSource', help_text='Optionally specify a payment source for this bill', null=True, verbose_name='payment source'), field=models.DateTimeField(null=True, verbose_name='closed on', blank=True),
preserve_default=True, preserve_default=True,
), ),
] ]

View File

@ -1,4 +1,5 @@
import inspect import inspect
from dateutil.relativedelta import relativedelta
from django.db import models from django.db import models
from django.template import loader, Context from django.template import loader, Context
@ -28,14 +29,12 @@ class Bill(models.Model):
CLOSED = 'CLOSED' CLOSED = 'CLOSED'
SENT = 'SENT' SENT = 'SENT'
PAID = 'PAID' PAID = 'PAID'
RETURNED = 'RETURNED'
BAD_DEBT = 'BAD_DEBT' BAD_DEBT = 'BAD_DEBT'
STATUSES = ( STATUSES = (
(OPEN, _("Open")), (OPEN, _("Open")),
(CLOSED, _("Closed")), (CLOSED, _("Closed")),
(SENT, _("Sent")), (SENT, _("Sent")),
(PAID, _("Paid")), (PAID, _("Paid")),
(RETURNED, _("Returned")),
(BAD_DEBT, _("Bad debt")), (BAD_DEBT, _("Bad debt")),
) )
@ -51,13 +50,11 @@ class Bill(models.Model):
blank=True) blank=True)
account = models.ForeignKey('accounts.Account', verbose_name=_("account"), account = models.ForeignKey('accounts.Account', verbose_name=_("account"),
related_name='%(class)s') 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) type = models.CharField(_("type"), max_length=16, choices=TYPES)
status = models.CharField(_("status"), max_length=16, choices=STATUSES, status = models.CharField(_("status"), max_length=16, choices=STATUSES,
default=OPEN) default=OPEN)
created_on = models.DateTimeField(_("created on"), auto_now_add=True) 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) due_on = models.DateField(_("due on"), null=True, blank=True)
last_modified_on = models.DateTimeField(_("last modified on"), auto_now=True) last_modified_on = models.DateTimeField(_("last modified on"), auto_now=True)
#base = models.DecimalField(max_digits=12, decimal_places=2) #base = models.DecimalField(max_digits=12, decimal_places=2)
@ -95,27 +92,40 @@ class Bill(models.Model):
bill_type = self.get_type() bill_type = self.get_type()
if bill_type == 'BILL': if bill_type == 'BILL':
raise TypeError("get_new_number() can not be used on a Bill class") 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) prefix = getattr(settings, 'BILLS_%s_NUMBER_PREFIX' % bill_type)
if self.status == self.OPEN: if self.status == self.OPEN:
prefix = 'O{}'.format(prefix) prefix = 'O{}'.format(prefix)
bills = bills.filter(status=self.OPEN) bills = cls.objects.filter(number__regex=r'^%s[1-9]+' % prefix)
num_bills = bills.order_by('-number').first() or 0 last_number = bills.order_by('-number').values_list('number', flat=True).first()
if num_bills is not 0: if last_number is None:
num_bills = int(num_bills.number[-number_length:]) last_number = 0
else: else:
bills = bills.exclude(status=self.OPEN) last_number = int(last_number[len(prefix)+4:])
num_bills = bills.count() number = last_number + 1
zeros = (number_length - len(str(num_bills))) * '0' year = timezone.now().strftime("%Y")
number = zeros + str(num_bills + 1) number_length = settings.BILLS_NUMBER_LENGTH
zeros = (number_length - len(str(number))) * '0'
number = zeros + str(number)
self.number = '{prefix}{year}{number}'.format( self.number = '{prefix}{year}{number}'.format(
prefix=prefix, year=year, number=number) prefix=prefix, year=year, number=number)
def close(self): def get_due_date(self, payment=None):
self.html = self.render() 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.status = self.CLOSED
self.save() self.save()
@ -131,13 +141,12 @@ class Bill(models.Model):
('%s.pdf' % self.number, html_to_pdf(self.html), 'application/pdf') ('%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.status = self.SENT
self.save() self.save()
def render(self): def render(self, payment=False):
if payment is False:
payment = self.account.paymentsources.get_default()
context = Context({ context = Context({
'bill': self, 'bill': self,
'lines': self.lines.all().prefetch_related('sublines'), 'lines': self.lines.all().prefetch_related('sublines'),
@ -147,8 +156,12 @@ class Bill(models.Model):
'phone': settings.BILLS_SELLER_PHONE, 'phone': settings.BILLS_SELLER_PHONE,
'website': settings.BILLS_SELLER_WEBSITE, 'website': settings.BILLS_SELLER_WEBSITE,
'email': settings.BILLS_SELLER_EMAIL, 'email': settings.BILLS_SELLER_EMAIL,
'bank_account': settings.BILLS_SELLER_BANK_ACCOUNT,
}, },
'currency': settings.BILLS_CURRENCY, '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(), template = getattr(settings, 'BILLS_%s_TEMPLATE' % self.get_type(),
settings.BILLS_DEFAULT_TEMPLATE) settings.BILLS_DEFAULT_TEMPLATE)

View File

@ -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_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', BILLS_EMAIL_NOTIFICATION_TEMPLATE = getattr(settings, 'BILLS_EMAIL_NOTIFICATION_TEMPLATE',

View File

@ -8,11 +8,9 @@
{% block breadcrumbs %} {% block breadcrumbs %}
TODO
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<h1>Are you sure you want to close selected bills</h1> <h1>Are you sure you want to close selected bills</h1>
<p>Once a bill is closed it can not be further modified.</p> <p>Once a bill is closed it can not be further modified.</p>
@ -20,7 +18,7 @@ TODO
<form action="" method="post">{% csrf_token %} <form action="" method="post">{% csrf_token %}
<div> <div>
<div style="margin:20px;"> <div style="margin:20px;">
{{ form.as_admin }} {{ formset }}
</div> </div>
{% for obj in queryset %} {% for obj in queryset %}
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ obj.pk|unlocalize }}" /> <input type="hidden" name="{{ action_checkbox_name }}" value="{{ obj.pk|unlocalize }}" />

View File

@ -51,13 +51,13 @@
font-weight: bold; font-weight: bold;
} }
#date { #date {
clear: left; clear: left;
clear: right; clear: right;
margin-top: 0px; margin-top: 0px;
padding-top: 0px; padding-top: 0px;
font-weight: bold; font-weight: bold;
color: #666;
} }
#text { #text {
@ -97,13 +97,13 @@ hr {
<div id="number" class="column-1"> <div id="number" class="column-1">
<span id="number-title">Membership Fee</span><br> <span id="number-title">Membership Fee</span><br>
<span id="number-value">{{ bill.number }}</span><br> <span id="number-value">{{ bill.number }}</span><br>
<span id="number-date">{{ bill.created_on | date }}</span><br> <span id="number-date">{{ bill.closed_on | default:now | date }}</span><br>
</div> </div>
<div id="amount" class="column-2"> <div id="amount" class="column-2">
<span id="amount-value">{{ bill.get_total }} &euro;</span><br> <span id="amount-value">{{ bill.get_total }} &euro;</span><br>
<span id="amount-note">To pay before {{ bill.due_date }}<br> <span id="amount-note">Due date {{ payment.due_date | default:default_due_date | date }}<br>
on 213.232.322.232.332<br> {% if not payment.message %}On {{ seller_info.bank_account }}{% endif %}<br>
</span> </span>
</div> </div>

View File

@ -46,7 +46,7 @@
<hr> <hr>
<div id="due-date"> <div id="due-date">
<span class="title">DUE DATE</span><br> <span class="title">DUE DATE</span><br>
<psan class="value">{{ bill.due_on|date }}</span> <psan class="value">{{ bill.due_on | default:default_due_date | date }}</span>
</div> </div>
<div id="total"> <div id="total">
<span class="title">TOTAL</span><br> <span class="title">TOTAL</span><br>
@ -54,7 +54,7 @@
</div> </div>
<div id="bill-date"> <div id="bill-date">
<span class="title">{{ bill.get_type_display.upper }} DATE</span><br> <span class="title">{{ bill.get_type_display.upper }} DATE</span><br>
<psan class="value">{{ bill.created_on|date }}</span> <psan class="value">{{ bill.closed_on | default:now | date }}</span>
</div> </div>
</div> </div>
<div id="buyer-details"> <div id="buyer-details">
@ -122,13 +122,13 @@
<div id="footer-column-2"> <div id="footer-column-2">
<div id="payment"> <div id="payment">
<span class="title">PAYMENT</span> <span class="title">PAYMENT</span>
{% if bill.payment.message %} {% if payment.message %}
{{ bill.payment.message }} {{ payment.message | safe }}
{% else %} {% else %}
You can pay our invoice by bank transfer. <br> You can pay our invoice by bank transfer. <br>
Please make sure to state your name and the invoice number. Please make sure to state your name and the invoice number.
Our bank account number is <br> Our bank account number is <br>
<strong>000-000-000-000 (Orchestra)</strong> <strong>{{ seller_info.bank_account }}</strong>
{% endif %} {% endif %}
</div> </div>
<div id="questions"> <div id="questions">

View File

@ -1,5 +1,7 @@
import datetime import datetime
from django.utils.translation import ugettext_lazy as _
from orchestra.apps.bills.models import Invoice, Fee, BillLine, BillSubline from orchestra.apps.bills.models import Invoice, Fee, BillLine, BillSubline
@ -15,9 +17,7 @@ class BillsBackend(object):
rate=service.nominal_price, rate=service.nominal_price,
amount=size, amount=size,
total=nominal_price, tax=0, total=nominal_price, tax=0,
description="{ini} to {end}".format( description=self.format_period(ini, end),
ini=ini.strftime("%b, %Y"),
end=(end-datetime.timedelta(seconds=1)).strftime("%b, %Y")),
) )
self.create_sublines(line, discounts) self.create_sublines(line, discounts)
bills.append(fee) bills.append(fee)
@ -28,9 +28,7 @@ class BillsBackend(object):
bills.append(invoice) bills.append(invoice)
description = order.description description = order.description
if service.billing_period != service.NEVER: if service.billing_period != service.NEVER:
description += " {ini} to {end}".format( description += " %s" % self.format_period(ini, end)
ini=ini.strftime("%b, %Y"),
end=(end-datetime.timedelta(seconds=1)).strftime("%b, %Y"))
line = invoice.lines.create( line = invoice.lines.create(
description=description, description=description,
rate=service.nominal_price, rate=service.nominal_price,
@ -41,6 +39,14 @@ class BillsBackend(object):
self.create_sublines(line, discounts) self.create_sublines(line, discounts)
return bills return bills
def format_period(self, ini, end):
ini = ini=ini.strftime("%b, %Y")
end = (end-datetime.timedelta(seconds=1)).strftime("%b, %Y")
if ini == end:
return ini
return _("{ini} to {end}").format(ini=ini, end=end)
def create_sublines(self, line, discounts): def create_sublines(self, line, discounts):
for name, value in discounts: for name, value in discounts:
line.sublines.create( line.sublines.create(

View File

@ -1,36 +1,61 @@
from django import forms from django import forms
from django.conf.urls import patterns, url
from django.contrib import admin from django.contrib import admin
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.shortcuts import render, redirect
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from orchestra.admin.utils import admin_colored, admin_link from orchestra.admin.utils import admin_colored, admin_link
from orchestra.apps.accounts.admin import AccountAdminMixin from orchestra.apps.accounts.admin import AccountAdminMixin
from .actions import process_transactions from .actions import process_transactions
from .methods import SEPADirectDebit from .methods import PaymentMethod
from .models import PaymentSource, Transaction, PaymentProcess from .models import PaymentSource, Transaction, TransactionProcess
STATE_COLORS = { STATE_COLORS = {
Transaction.WAITTING_PROCESSING: 'darkorange', Transaction.WAITTING_PROCESSING: 'darkorange',
Transaction.WAITTING_CONFIRMATION: 'purple', Transaction.WAITTING_CONFIRMATION: 'magenta',
Transaction.CONFIRMED: 'green', Transaction.CONFIRMED: 'olive',
Transaction.SECURED: 'green',
Transaction.REJECTED: 'red', Transaction.REJECTED: 'red',
Transaction.LOCKED: 'magenta',
Transaction.DISCARTED: 'blue', Transaction.DISCARTED: 'blue',
} }
class TransactionInline(admin.TabularInline):
model = Transaction
can_delete = False
extra = 0
fields = ('transaction_link', 'bill_link', 'source_link', 'display_state', 'amount', 'currency')
readonly_fields = fields
transaction_link = admin_link('__unicode__', short_description=_("ID"))
bill_link = admin_link('bill')
source_link = admin_link('source')
display_state = admin_colored('state', colors=STATE_COLORS)
class Media:
css = {
'all': ('orchestra/css/hide-inline-id.css',)
}
def has_add_permission(self, *args, **kwargs):
return False
class TransactionAdmin(AccountAdminMixin, admin.ModelAdmin): class TransactionAdmin(AccountAdminMixin, admin.ModelAdmin):
list_display = ( list_display = (
'id', 'bill_link', 'account_link', 'source_link', 'display_state', 'amount' 'id', 'bill_link', 'account_link', 'source_link', 'display_state', 'amount', 'process_link'
) )
list_filter = ('source__method', 'state') list_filter = ('source__method', 'state')
actions = (process_transactions,) actions = (process_transactions,)
filter_by_account_fields = ['source'] filter_by_account_fields = ['source']
readonly_fields = ('process_link', 'account_link')
bill_link = admin_link('bill') bill_link = admin_link('bill')
source_link = admin_link('source') source_link = admin_link('source')
process_link = admin_link('process', short_description=_("proc"))
account_link = admin_link('bill__account') account_link = admin_link('bill__account')
display_state = admin_colored('state', colors=STATE_COLORS) display_state = admin_colored('state', colors=STATE_COLORS)
@ -47,14 +72,51 @@ class PaymentSourceAdmin(AccountAdminMixin, admin.ModelAdmin):
if obj: if obj:
self.form = obj.method_class().get_form() self.form = obj.method_class().get_form()
else: else:
self.form = forms.ModelForm self.form = PaymentMethod.get_plugin(self.method)().get_form()
return super(PaymentSourceAdmin, self).get_form(request, obj=obj, **kwargs) return super(PaymentSourceAdmin, self).get_form(request, obj=obj, **kwargs)
def get_urls(self):
""" Hooks select account url """
urls = super(PaymentSourceAdmin, self).get_urls()
admin_site = self.admin_site
opts = self.model._meta
info = opts.app_label, opts.model_name
select_urls = patterns("",
url("/select-method/$",
self.select_method_view,
name='%s_%s_select_method' % info),
)
return select_urls + urls
def select_method_view(self, request):
context = {
'methods': PaymentMethod.get_plugin_choices(),
}
return render(request, 'admin/payments/payment_source/select_method.html', context)
def add_view(self, request, form_url='', extra_context=None):
""" Redirects to select account view if required """
if request.user.is_superuser:
method = request.GET.get('method')
if method or PaymentMethod.get_plugins() == 1:
self.method = method
if not method:
self.method = PaymentMethod.get_plugins()[0]
return super(PaymentSourceAdmin, self).add_view(request,
form_url=form_url, extra_context=extra_context)
return redirect('./select-method/?%s' % request.META['QUERY_STRING'])
def save_model(self, request, obj, form, change):
if not change:
obj.method = self.method
obj.save()
class PaymentProcessAdmin(admin.ModelAdmin): class TransactionProcessAdmin(admin.ModelAdmin):
list_display = ('id', 'file_url', 'display_transactions', 'created_at') list_display = ('id', 'file_url', 'display_transactions', 'created_at')
fields = ('data', 'file_url', 'display_transactions', 'created_at') fields = ('data', 'file_url', 'display_transactions', 'created_at')
readonly_fields = ('file_url', 'display_transactions', 'created_at') readonly_fields = ('file_url', 'display_transactions', 'created_at')
inlines = [TransactionInline]
def file_url(self, process): def file_url(self, process):
if process.file: if process.file:
@ -85,4 +147,4 @@ class PaymentProcessAdmin(admin.ModelAdmin):
admin.site.register(PaymentSource, PaymentSourceAdmin) admin.site.register(PaymentSource, PaymentSourceAdmin)
admin.site.register(Transaction, TransactionAdmin) admin.site.register(Transaction, TransactionAdmin)
admin.site.register(PaymentProcess, PaymentProcessAdmin) admin.site.register(TransactionProcess, TransactionProcessAdmin)

View File

@ -1,40 +0,0 @@
from django import forms
from django.utils.translation import ugettext_lazy as _
# TODO this is for the billing phase
class TransactionCreationForm(forms.ModelForm):
# transaction_link = forms.CharField()
# account_link = forms.CharField()
# bill_link = forms.CharField()
source = forms.ChoiceField(required=False)
# exclude = forms.BooleanField(required=False)
# class Meta:
# model = Bill ?
def __init__(self, *args, **kwargs):
super(SourceSelectionForm, self).__init__(*args, **kwargs)
bill = kwargs.get('instance')
if bill:
sources = bill.account.payment_sources.filter(is_active=True)
choices = []
for source in sources:
if bill.ammount < 0:
if source.method_class().allow_recharge:
choices.append((source.method, source.method_display()))
else:
choices.append((source.method, source.method_display()))
self.fields['source'].choices = choices
# def clean(self):
# cleaned_data = super(SourceSelectionForm, self).clean()
# method = cleaned_data.get("method")
# exclude = cleaned_data.get("exclude")
# if not method and not exclude:
# raise forms.ValidationError(_("A transaction should be explicitly "
# "excluded when no payment source is available."))
class ProcessTransactionForm(forms.ModelForm):
pass

View File

@ -1,3 +1,4 @@
from dateutil import relativedelta
from django import forms from django import forms
from orchestra.utils import plugins from orchestra.utils import plugins
@ -9,6 +10,7 @@ class PaymentMethod(plugins.Plugin):
process_credit = False process_credit = False
form = None form = None
serializer = None serializer = None
due_delta = relativedelta.relativedelta(months=1)
__metaclass__ = plugins.PluginMount __metaclass__ = plugins.PluginMount
@ -25,6 +27,9 @@ class PaymentMethod(plugins.Plugin):
def get_number(self, data): def get_number(self, data):
return data[self.number_field] return data[self.number_field]
def get_bill_message(self, source):
raise NotImplementedError
class PaymentSourceDataForm(forms.ModelForm): class PaymentSourceDataForm(forms.ModelForm):

View File

@ -1,5 +1,6 @@
import os import datetime
import lxml.builder import lxml.builder
import os
from lxml import etree from lxml import etree
from lxml.builder import E from lxml.builder import E
from StringIO import StringIO from StringIO import StringIO
@ -35,6 +36,11 @@ class SEPADirectDebit(PaymentMethod):
process_credit = True process_credit = True
form = SEPADirectDebitForm form = SEPADirectDebitForm
serializer = SEPADirectDebitSerializer serializer = SEPADirectDebitSerializer
due_delta = datetime.timedelta(days=5)
def get_bill_message(self, source):
return _("This bill will been automatically charged to your bank account "
" with IBAN number<br><strong>%s</strong>.") % source.number
def process(self, transactions): def process(self, transactions):
debts = [] debts = []
@ -50,8 +56,8 @@ class SEPADirectDebit(PaymentMethod):
self._process_credits(credits) self._process_credits(credits)
def _process_credits(self, transactions): def _process_credits(self, transactions):
from ..models import PaymentProcess from ..models import TransactionProcess
self.object = PaymentProcess.objects.create() self.process = TransactionProcess.objects.create()
context = self.get_context(transactions) context = self.get_context(transactions)
sepa = lxml.builder.ElementMaker( sepa = lxml.builder.ElementMaker(
nsmap = { nsmap = {
@ -63,7 +69,7 @@ class SEPADirectDebit(PaymentMethod):
E.CstmrCdtTrfInitn( E.CstmrCdtTrfInitn(
self._get_header(context), self._get_header(context),
E.PmtInf( # Payment Info E.PmtInf( # Payment Info
E.PmtInfId(str(self.object.id)), # Payment Id E.PmtInfId(str(self.process.id)), # Payment Id
E.PmtMtd("TRF"), # Payment Method E.PmtMtd("TRF"), # Payment Method
E.NbOfTxs(context['num_transactions']), # Number of Transactions E.NbOfTxs(context['num_transactions']), # Number of Transactions
E.CtrlSum(context['total']), # Control Sum E.CtrlSum(context['total']), # Control Sum
@ -87,12 +93,12 @@ class SEPADirectDebit(PaymentMethod):
) )
) )
) )
file_name = 'credit-transfer-%i.xml' % self.object.id file_name = 'credit-transfer-%i.xml' % self.process.id
self._process_xml(sepa, 'pain.001.001.03.xsd', file_name) self._process_xml(sepa, 'pain.001.001.03.xsd', file_name)
def _process_debts(self, transactions): def _process_debts(self, transactions):
from ..models import PaymentProcess from ..models import TransactionProcess
self.object = PaymentProcess.objects.create() self.process = TransactionProcess.objects.create()
context = self.get_context(transactions) context = self.get_context(transactions)
sepa = lxml.builder.ElementMaker( sepa = lxml.builder.ElementMaker(
nsmap = { nsmap = {
@ -104,7 +110,7 @@ class SEPADirectDebit(PaymentMethod):
E.CstmrDrctDbtInitn( E.CstmrDrctDbtInitn(
self._get_header(context), self._get_header(context),
E.PmtInf( # Payment Info E.PmtInf( # Payment Info
E.PmtInfId(str(self.object.id)), # Payment Id E.PmtInfId(str(self.process.id)), # Payment Id
E.PmtMtd("DD"), # Payment Method E.PmtMtd("DD"), # Payment Method
E.NbOfTxs(context['num_transactions']), # Number of Transactions E.NbOfTxs(context['num_transactions']), # Number of Transactions
E.CtrlSum(context['total']), # Control Sum E.CtrlSum(context['total']), # Control Sum
@ -137,7 +143,7 @@ class SEPADirectDebit(PaymentMethod):
) )
) )
) )
file_name = 'direct-debit-%i.xml' % self.object.id file_name = 'direct-debit-%i.xml' % self.process.id
self._process_xml(sepa, 'pain.008.001.02.xsd', file_name) self._process_xml(sepa, 'pain.008.001.02.xsd', file_name)
def get_context(self, transactions): def get_context(self, transactions):
@ -153,10 +159,9 @@ class SEPADirectDebit(PaymentMethod):
def _get_debt_transactions(self, transactions): def _get_debt_transactions(self, transactions):
for transaction in transactions: for transaction in transactions:
self.object.transactions.add(transaction) transaction.process = self.process
account = transaction.account account = transaction.account
# TODO data = transaction.source.data
data = account.paymentsources.first().data
transaction.state = transaction.WAITTING_CONFIRMATION transaction.state = transaction.WAITTING_CONFIRMATION
transaction.save() transaction.save()
yield E.DrctDbtTxInf( # Direct Debit Transaction Info yield E.DrctDbtTxInf( # Direct Debit Transaction Info
@ -194,10 +199,9 @@ class SEPADirectDebit(PaymentMethod):
def _get_credit_transactions(self, transactions): def _get_credit_transactions(self, transactions):
for transaction in transactions: for transaction in transactions:
self.object.transactions.add(transaction) transaction.process = self.process
account = transaction.account account = transaction.account
# FIXME data = transaction.source.data
data = account.payment_sources.first().data
transaction.state = transaction.WAITTING_CONFIRMATION transaction.state = transaction.WAITTING_CONFIRMATION
transaction.save() transaction.save()
yield E.CdtTrfTxInf( # Credit Transfer Transaction Info yield E.CdtTrfTxInf( # Credit Transfer Transaction Info
@ -229,7 +233,7 @@ class SEPADirectDebit(PaymentMethod):
def _get_header(self, context): def _get_header(self, context):
return E.GrpHdr( # Group Header return E.GrpHdr( # Group Header
E.MsgId(str(self.object.id)), # Message Id E.MsgId(str(self.process.id)), # Message Id
E.CreDtTm( # Creation Date Time E.CreDtTm( # Creation Date Time
context['now'].strftime("%Y-%m-%dT%H:%M:%S") context['now'].strftime("%Y-%m-%dT%H:%M:%S")
), ),
@ -255,9 +259,9 @@ class SEPADirectDebit(PaymentMethod):
schema = etree.XMLSchema(schema_doc) schema = etree.XMLSchema(schema_doc)
sepa = etree.parse(StringIO(etree.tostring(sepa))) sepa = etree.parse(StringIO(etree.tostring(sepa)))
schema.assertValid(sepa) schema.assertValid(sepa)
self.object.file = file_name self.process.file = file_name
self.object.save() self.process.save()
sepa.write(self.object.file.path, sepa.write(self.process.file.path,
pretty_print=True, pretty_print=True,
xml_declaration=True, xml_declaration=True,
encoding='UTF-8') encoding='UTF-8')

View File

@ -1,5 +1,6 @@
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from jsonfield import JSONField from jsonfield import JSONField
@ -12,8 +13,7 @@ from .methods import PaymentMethod
class PaymentSourcesQueryset(models.QuerySet): class PaymentSourcesQueryset(models.QuerySet):
def get_source(self): def get_default(self):
# TODO
return self.filter(is_active=True).first() return self.filter(is_active=True).first()
@ -41,6 +41,15 @@ class PaymentSource(models.Model):
@cached_property @cached_property
def number(self): def number(self):
return self.method_class().get_number(self.data) return self.method_class().get_number(self.data)
def get_bill_context(self):
method = self.method_class()
return {
'message': method.get_bill_message(self),
}
def get_due_delta(self):
return self.method_class().due_delta
class TransactionQuerySet(models.QuerySet): class TransactionQuerySet(models.QuerySet):
@ -53,14 +62,14 @@ class Transaction(models.Model):
WAITTING_CONFIRMATION = 'WAITTING_CONFIRMATION' WAITTING_CONFIRMATION = 'WAITTING_CONFIRMATION'
CONFIRMED = 'CONFIRMED' CONFIRMED = 'CONFIRMED'
REJECTED = 'REJECTED' REJECTED = 'REJECTED'
LOCKED = 'LOCKED'
DISCARTED = 'DISCARTED' DISCARTED = 'DISCARTED'
SECURED = 'SECURED'
STATES = ( STATES = (
(WAITTING_PROCESSING, _("Waitting processing")), (WAITTING_PROCESSING, _("Waitting processing")),
(WAITTING_CONFIRMATION, _("Waitting confirmation")), (WAITTING_CONFIRMATION, _("Waitting confirmation")),
(CONFIRMED, _("Confirmed")), (CONFIRMED, _("Confirmed")),
(REJECTED, _("Rejected")), (REJECTED, _("Rejected")),
(LOCKED, _("Locked")), (SECURED, _("Secured")),
(DISCARTED, _("Discarted")), (DISCARTED, _("Discarted")),
) )
@ -70,6 +79,8 @@ class Transaction(models.Model):
related_name='transactions') related_name='transactions')
source = models.ForeignKey(PaymentSource, null=True, blank=True, source = models.ForeignKey(PaymentSource, null=True, blank=True,
verbose_name=_("source"), related_name='transactions') verbose_name=_("source"), related_name='transactions')
process = models.ForeignKey('payments.TransactionProcess', null=True,
blank=True, verbose_name=_("process"), related_name='transactions')
state = models.CharField(_("state"), max_length=32, choices=STATES, state = models.CharField(_("state"), max_length=32, choices=STATES,
default=WAITTING_PROCESSING) default=WAITTING_PROCESSING)
amount = models.DecimalField(_("amount"), max_digits=12, decimal_places=2) amount = models.DecimalField(_("amount"), max_digits=12, decimal_places=2)
@ -85,18 +96,16 @@ class Transaction(models.Model):
return self.bill.account return self.bill.account
# TODO rename to TransactionProcess or PaymentRequest TransactionRequest class TransactionProcess(models.Model):
class PaymentProcess(models.Model):
""" """
Stores arbitrary data generated by payment methods while processing transactions Stores arbitrary data generated by payment methods while processing transactions
""" """
transactions = models.ManyToManyField(Transaction, related_name='processes',
verbose_name=_("transactions"))
data = JSONField(_("data"), blank=True) data = JSONField(_("data"), blank=True)
file = models.FileField(_("file"), blank=True) file = models.FileField(_("file"), blank=True)
created_at = models.DateTimeField(_("created at"), auto_now_add=True) created_at = models.DateTimeField(_("created at"), auto_now_add=True)
# TODO state: created, commited, secured (delayed persistence) class Meta:
verbose_name_plural = _("Transaction processes")
def __unicode__(self): def __unicode__(self):
return str(self.id) return str(self.id)

View File

@ -0,0 +1,28 @@
{% extends "admin/base_site.html" %}
{% load i18n l10n staticfiles admin_urls %}
{% block extrastyle %}
{{ block.super }}
<link rel="stylesheet" type="text/css" href="{% static "admin/css/forms.css" %}" />
{% endblock %}
{% block breadcrumbs %}
TODO
{% endblock %}
{% block content %}
<h1>Select a method for the new payment source</h1>
<form action="" method="post">{% csrf_token %}
<div>
<div style="margin:20px;">
<ul>
{% for name, verbose in methods %}
<li><a href="../?method={{ name }}&{{ request.META.QUERY_STRING }}">{{ verbose }}</<a></li>
{% endfor %}
</ul>
</div>
{% endblock %}