Improvements on bills and payment management
This commit is contained in:
parent
13df742284
commit
4c603bf584
5
TODO.md
5
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!
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
34
orchestra/apps/bills/forms.py
Normal file
34
orchestra/apps/bills/forms.py
Normal 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
|
|
@ -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')),
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
]
|
|
@ -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)
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -8,11 +8,9 @@
|
|||
|
||||
|
||||
{% block breadcrumbs %}
|
||||
TODO
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block content %}
|
||||
<h1>Are you sure you want to close selected bills</h1>
|
||||
<p>Once a bill is closed it can not be further modified.</p>
|
||||
|
@ -20,7 +18,7 @@ TODO
|
|||
<form action="" method="post">{% csrf_token %}
|
||||
<div>
|
||||
<div style="margin:20px;">
|
||||
{{ form.as_admin }}
|
||||
{{ formset }}
|
||||
</div>
|
||||
{% for obj in queryset %}
|
||||
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ obj.pk|unlocalize }}" />
|
||||
|
|
|
@ -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 {
|
|||
<div id="number" class="column-1">
|
||||
<span id="number-title">Membership Fee</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 id="amount" class="column-2">
|
||||
<span id="amount-value">{{ bill.get_total }} €</span><br>
|
||||
<span id="amount-note">To pay before {{ bill.due_date }}<br>
|
||||
on 213.232.322.232.332<br>
|
||||
<span id="amount-note">Due date {{ payment.due_date | default:default_due_date | date }}<br>
|
||||
{% if not payment.message %}On {{ seller_info.bank_account }}{% endif %}<br>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -46,7 +46,7 @@
|
|||
<hr>
|
||||
<div id="due-date">
|
||||
<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 id="total">
|
||||
<span class="title">TOTAL</span><br>
|
||||
|
@ -54,7 +54,7 @@
|
|||
</div>
|
||||
<div id="bill-date">
|
||||
<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 id="buyer-details">
|
||||
|
@ -122,13 +122,13 @@
|
|||
<div id="footer-column-2">
|
||||
<div id="payment">
|
||||
<span class="title">PAYMENT</span>
|
||||
{% if bill.payment.message %}
|
||||
{{ bill.payment.message }}
|
||||
{% if payment.message %}
|
||||
{{ payment.message | safe }}
|
||||
{% else %}
|
||||
You can pay our invoice by bank transfer. <br>
|
||||
Please make sure to state your name and the invoice number.
|
||||
Our bank account number is <br>
|
||||
<strong>000-000-000-000 (Orchestra)</strong>
|
||||
<strong>{{ seller_info.bank_account }}</strong>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="questions">
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import datetime
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from orchestra.apps.bills.models import Invoice, Fee, BillLine, BillSubline
|
||||
|
||||
|
||||
|
@ -15,9 +17,7 @@ class BillsBackend(object):
|
|||
rate=service.nominal_price,
|
||||
amount=size,
|
||||
total=nominal_price, tax=0,
|
||||
description="{ini} to {end}".format(
|
||||
ini=ini.strftime("%b, %Y"),
|
||||
end=(end-datetime.timedelta(seconds=1)).strftime("%b, %Y")),
|
||||
description=self.format_period(ini, end),
|
||||
)
|
||||
self.create_sublines(line, discounts)
|
||||
bills.append(fee)
|
||||
|
@ -28,9 +28,7 @@ class BillsBackend(object):
|
|||
bills.append(invoice)
|
||||
description = order.description
|
||||
if service.billing_period != service.NEVER:
|
||||
description += " {ini} to {end}".format(
|
||||
ini=ini.strftime("%b, %Y"),
|
||||
end=(end-datetime.timedelta(seconds=1)).strftime("%b, %Y"))
|
||||
description += " %s" % self.format_period(ini, end)
|
||||
line = invoice.lines.create(
|
||||
description=description,
|
||||
rate=service.nominal_price,
|
||||
|
@ -41,6 +39,14 @@ class BillsBackend(object):
|
|||
self.create_sublines(line, discounts)
|
||||
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):
|
||||
for name, value in discounts:
|
||||
line.sublines.create(
|
||||
|
|
|
@ -1,36 +1,61 @@
|
|||
from django import forms
|
||||
from django.conf.urls import patterns, url
|
||||
from django.contrib import admin
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.shortcuts import render, redirect
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from orchestra.admin.utils import admin_colored, admin_link
|
||||
from orchestra.apps.accounts.admin import AccountAdminMixin
|
||||
|
||||
from .actions import process_transactions
|
||||
from .methods import SEPADirectDebit
|
||||
from .models import PaymentSource, Transaction, PaymentProcess
|
||||
from .methods import PaymentMethod
|
||||
from .models import PaymentSource, Transaction, TransactionProcess
|
||||
|
||||
|
||||
STATE_COLORS = {
|
||||
Transaction.WAITTING_PROCESSING: 'darkorange',
|
||||
Transaction.WAITTING_CONFIRMATION: 'purple',
|
||||
Transaction.CONFIRMED: 'green',
|
||||
Transaction.WAITTING_CONFIRMATION: 'magenta',
|
||||
Transaction.CONFIRMED: 'olive',
|
||||
Transaction.SECURED: 'green',
|
||||
Transaction.REJECTED: 'red',
|
||||
Transaction.LOCKED: 'magenta',
|
||||
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):
|
||||
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')
|
||||
actions = (process_transactions,)
|
||||
filter_by_account_fields = ['source']
|
||||
readonly_fields = ('process_link', 'account_link')
|
||||
|
||||
bill_link = admin_link('bill')
|
||||
source_link = admin_link('source')
|
||||
process_link = admin_link('process', short_description=_("proc"))
|
||||
account_link = admin_link('bill__account')
|
||||
display_state = admin_colored('state', colors=STATE_COLORS)
|
||||
|
||||
|
@ -47,14 +72,51 @@ class PaymentSourceAdmin(AccountAdminMixin, admin.ModelAdmin):
|
|||
if obj:
|
||||
self.form = obj.method_class().get_form()
|
||||
else:
|
||||
self.form = forms.ModelForm
|
||||
self.form = PaymentMethod.get_plugin(self.method)().get_form()
|
||||
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
|
||||
|
||||
class PaymentProcessAdmin(admin.ModelAdmin):
|
||||
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 TransactionProcessAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'file_url', 'display_transactions', 'created_at')
|
||||
fields = ('data', 'file_url', 'display_transactions', 'created_at')
|
||||
readonly_fields = ('file_url', 'display_transactions', 'created_at')
|
||||
inlines = [TransactionInline]
|
||||
|
||||
def file_url(self, process):
|
||||
if process.file:
|
||||
|
@ -85,4 +147,4 @@ class PaymentProcessAdmin(admin.ModelAdmin):
|
|||
|
||||
admin.site.register(PaymentSource, PaymentSourceAdmin)
|
||||
admin.site.register(Transaction, TransactionAdmin)
|
||||
admin.site.register(PaymentProcess, PaymentProcessAdmin)
|
||||
admin.site.register(TransactionProcess, TransactionProcessAdmin)
|
||||
|
|
|
@ -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
|
|
@ -1,3 +1,4 @@
|
|||
from dateutil import relativedelta
|
||||
from django import forms
|
||||
|
||||
from orchestra.utils import plugins
|
||||
|
@ -9,6 +10,7 @@ class PaymentMethod(plugins.Plugin):
|
|||
process_credit = False
|
||||
form = None
|
||||
serializer = None
|
||||
due_delta = relativedelta.relativedelta(months=1)
|
||||
|
||||
__metaclass__ = plugins.PluginMount
|
||||
|
||||
|
@ -26,6 +28,9 @@ class PaymentMethod(plugins.Plugin):
|
|||
def get_number(self, data):
|
||||
return data[self.number_field]
|
||||
|
||||
def get_bill_message(self, source):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class PaymentSourceDataForm(forms.ModelForm):
|
||||
class Meta:
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import os
|
||||
import datetime
|
||||
import lxml.builder
|
||||
import os
|
||||
from lxml import etree
|
||||
from lxml.builder import E
|
||||
from StringIO import StringIO
|
||||
|
@ -35,6 +36,11 @@ class SEPADirectDebit(PaymentMethod):
|
|||
process_credit = True
|
||||
form = SEPADirectDebitForm
|
||||
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):
|
||||
debts = []
|
||||
|
@ -50,8 +56,8 @@ class SEPADirectDebit(PaymentMethod):
|
|||
self._process_credits(credits)
|
||||
|
||||
def _process_credits(self, transactions):
|
||||
from ..models import PaymentProcess
|
||||
self.object = PaymentProcess.objects.create()
|
||||
from ..models import TransactionProcess
|
||||
self.process = TransactionProcess.objects.create()
|
||||
context = self.get_context(transactions)
|
||||
sepa = lxml.builder.ElementMaker(
|
||||
nsmap = {
|
||||
|
@ -63,7 +69,7 @@ class SEPADirectDebit(PaymentMethod):
|
|||
E.CstmrCdtTrfInitn(
|
||||
self._get_header(context),
|
||||
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.NbOfTxs(context['num_transactions']), # Number of Transactions
|
||||
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)
|
||||
|
||||
def _process_debts(self, transactions):
|
||||
from ..models import PaymentProcess
|
||||
self.object = PaymentProcess.objects.create()
|
||||
from ..models import TransactionProcess
|
||||
self.process = TransactionProcess.objects.create()
|
||||
context = self.get_context(transactions)
|
||||
sepa = lxml.builder.ElementMaker(
|
||||
nsmap = {
|
||||
|
@ -104,7 +110,7 @@ class SEPADirectDebit(PaymentMethod):
|
|||
E.CstmrDrctDbtInitn(
|
||||
self._get_header(context),
|
||||
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.NbOfTxs(context['num_transactions']), # Number of Transactions
|
||||
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)
|
||||
|
||||
def get_context(self, transactions):
|
||||
|
@ -153,10 +159,9 @@ class SEPADirectDebit(PaymentMethod):
|
|||
|
||||
def _get_debt_transactions(self, transactions):
|
||||
for transaction in transactions:
|
||||
self.object.transactions.add(transaction)
|
||||
transaction.process = self.process
|
||||
account = transaction.account
|
||||
# TODO
|
||||
data = account.paymentsources.first().data
|
||||
data = transaction.source.data
|
||||
transaction.state = transaction.WAITTING_CONFIRMATION
|
||||
transaction.save()
|
||||
yield E.DrctDbtTxInf( # Direct Debit Transaction Info
|
||||
|
@ -194,10 +199,9 @@ class SEPADirectDebit(PaymentMethod):
|
|||
|
||||
def _get_credit_transactions(self, transactions):
|
||||
for transaction in transactions:
|
||||
self.object.transactions.add(transaction)
|
||||
transaction.process = self.process
|
||||
account = transaction.account
|
||||
# FIXME
|
||||
data = account.payment_sources.first().data
|
||||
data = transaction.source.data
|
||||
transaction.state = transaction.WAITTING_CONFIRMATION
|
||||
transaction.save()
|
||||
yield E.CdtTrfTxInf( # Credit Transfer Transaction Info
|
||||
|
@ -229,7 +233,7 @@ class SEPADirectDebit(PaymentMethod):
|
|||
|
||||
def _get_header(self, context):
|
||||
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
|
||||
context['now'].strftime("%Y-%m-%dT%H:%M:%S")
|
||||
),
|
||||
|
@ -255,9 +259,9 @@ class SEPADirectDebit(PaymentMethod):
|
|||
schema = etree.XMLSchema(schema_doc)
|
||||
sepa = etree.parse(StringIO(etree.tostring(sepa)))
|
||||
schema.assertValid(sepa)
|
||||
self.object.file = file_name
|
||||
self.object.save()
|
||||
sepa.write(self.object.file.path,
|
||||
self.process.file = file_name
|
||||
self.process.save()
|
||||
sepa.write(self.process.file.path,
|
||||
pretty_print=True,
|
||||
xml_declaration=True,
|
||||
encoding='UTF-8')
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from jsonfield import JSONField
|
||||
|
@ -12,8 +13,7 @@ from .methods import PaymentMethod
|
|||
|
||||
|
||||
class PaymentSourcesQueryset(models.QuerySet):
|
||||
def get_source(self):
|
||||
# TODO
|
||||
def get_default(self):
|
||||
return self.filter(is_active=True).first()
|
||||
|
||||
|
||||
|
@ -42,6 +42,15 @@ class PaymentSource(models.Model):
|
|||
def number(self):
|
||||
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):
|
||||
group_by = group_by
|
||||
|
@ -53,14 +62,14 @@ class Transaction(models.Model):
|
|||
WAITTING_CONFIRMATION = 'WAITTING_CONFIRMATION'
|
||||
CONFIRMED = 'CONFIRMED'
|
||||
REJECTED = 'REJECTED'
|
||||
LOCKED = 'LOCKED'
|
||||
DISCARTED = 'DISCARTED'
|
||||
SECURED = 'SECURED'
|
||||
STATES = (
|
||||
(WAITTING_PROCESSING, _("Waitting processing")),
|
||||
(WAITTING_CONFIRMATION, _("Waitting confirmation")),
|
||||
(CONFIRMED, _("Confirmed")),
|
||||
(REJECTED, _("Rejected")),
|
||||
(LOCKED, _("Locked")),
|
||||
(SECURED, _("Secured")),
|
||||
(DISCARTED, _("Discarted")),
|
||||
)
|
||||
|
||||
|
@ -70,6 +79,8 @@ class Transaction(models.Model):
|
|||
related_name='transactions')
|
||||
source = models.ForeignKey(PaymentSource, null=True, blank=True,
|
||||
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,
|
||||
default=WAITTING_PROCESSING)
|
||||
amount = models.DecimalField(_("amount"), max_digits=12, decimal_places=2)
|
||||
|
@ -85,18 +96,16 @@ class Transaction(models.Model):
|
|||
return self.bill.account
|
||||
|
||||
|
||||
# TODO rename to TransactionProcess or PaymentRequest TransactionRequest
|
||||
class PaymentProcess(models.Model):
|
||||
class TransactionProcess(models.Model):
|
||||
"""
|
||||
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)
|
||||
file = models.FileField(_("file"), blank=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):
|
||||
return str(self.id)
|
||||
|
|
|
@ -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 %}
|
||||
|
||||
|
Loading…
Reference in a new issue