summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--postgresqleu/confreg/backendforms.py116
-rw-r--r--postgresqleu/confreg/backendviews.py8
-rw-r--r--postgresqleu/confreg/invoicehandler.py54
-rw-r--r--postgresqleu/confreg/migrations/0040_refund_patterns.py28
-rw-r--r--postgresqleu/confreg/models.py10
-rw-r--r--postgresqleu/confreg/util.py31
-rw-r--r--postgresqleu/confreg/views.py112
-rw-r--r--postgresqleu/confsponsor/invoicehandler.py39
-rw-r--r--postgresqleu/invoices/admin.py1
-rw-r--r--postgresqleu/invoices/forms.py46
-rw-r--r--postgresqleu/invoices/migrations/0009_invoices_new_refunds.py35
-rw-r--r--postgresqleu/invoices/models.py20
-rw-r--r--postgresqleu/invoices/util.py60
-rw-r--r--postgresqleu/invoices/views.py58
-rw-r--r--postgresqleu/membership/invoicehandler.py7
-rw-r--r--postgresqleu/urls.py6
-rwxr-xr-xpostgresqleu/util/misc/baseinvoice.py36
-rw-r--r--template/confreg/admin_dashboard_single.html3
-rw-r--r--template/confreg/admin_registration_cancel.html63
-rw-r--r--template/confreg/admin_registration_cancel_confirm.html21
-rw-r--r--template/confreg/admin_registration_single.html2
-rw-r--r--template/confsponsor/admin_sponsor_details.html4
-rw-r--r--template/invoices/home.html7
-rw-r--r--template/invoices/invoiceform.html106
-rw-r--r--template/invoices/mail/invoice_refund.txt2
-rw-r--r--template/invoices/mail/invoice_refund_initiated.txt2
-rw-r--r--template/invoices/refundform.html223
-rw-r--r--template/invoices/refundform_complete.html48
-rw-r--r--template/invoices/userhome.html6
-rw-r--r--template/invoices/userinvoice_spec.html25
30 files changed, 680 insertions, 499 deletions
diff --git a/postgresqleu/confreg/backendforms.py b/postgresqleu/confreg/backendforms.py
index f7255dec..baf8327b 100644
--- a/postgresqleu/confreg/backendforms.py
+++ b/postgresqleu/confreg/backendforms.py
@@ -1,13 +1,16 @@
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator, MaxValueValidator
from django.db.models import Q
+from django.db.models.expressions import F
import django.forms
import django.forms.widgets
from django.utils.safestring import mark_safe
+from django.conf import settings
import datetime
from collections import OrderedDict
from psycopg2.extras import DateTimeTZRange
+from decimal import Decimal
from postgresqleu.util.forms import ConcurrentProtectedModelForm
from postgresqleu.util.random import generate_random_token
@@ -24,6 +27,7 @@ from postgresqleu.confreg.models import DiscountCode, AccessToken, AccessTokenPe
from postgresqleu.confreg.models import ConferenceSeries
from postgresqleu.confreg.models import ConferenceNews
from postgresqleu.confreg.models import ShirtSize
+from postgresqleu.confreg.models import RefundPattern
from postgresqleu.newsevents.models import NewsPosterProfile
from postgresqleu.confreg.models import valid_status_transitions, get_status_string
@@ -358,6 +362,49 @@ class BackendTransformConferenceDateTimeForm(django.forms.Form):
return str(self.cleaned_data['timeshift'])
+class BackendRefundPatternForm(BackendForm):
+ helplink = 'registration'
+ list_fields = ['fromdate', 'todate', 'percent', 'fees', ]
+ list_order_by = (F('fromdate').asc(nulls_first=True), 'todate', 'percent')
+ exclude_date_validators = ['fromdate', 'todate', ]
+ allow_copy_previous = True
+ copy_transform_form = BackendTransformConferenceDateTimeForm
+
+ class Meta:
+ model = RefundPattern
+ fields = ['percent', 'fees', 'fromdate', 'todate', ]
+
+ def clean(self):
+ cleaned_data = super(BackendRefundPatternForm, self).clean()
+ if cleaned_data['fromdate'] and cleaned_data['todate']:
+ if cleaned_data['todate'] < cleaned_data['fromdate']:
+ self.add_error('todate', 'To date must be after from date!')
+ return cleaned_data
+
+ @classmethod
+ def copy_from_conference(self, targetconf, sourceconf, idlist, transformform):
+ xform = transformform.cleaned_data['timeshift']
+ for id in idlist:
+ source = RefundPattern.objects.get(conference=sourceconf, pk=id)
+ RefundPattern(conference=targetconf,
+ percent=source.percent,
+ fees=source.fees,
+ fromdate=source.fromdate and source.fromdate + xform or None,
+ todate=source.todate and source.todate + xform or None,
+ ).save()
+ return
+ yield None # Turn this into a generator
+
+ @classmethod
+ def get_transform_example(self, targetconf, sourceconf, idlist, transformform):
+ xform = transformform.cleaned_data['timeshift']
+ if not idlist:
+ return None
+ s = RefundPattern.objects.filter(conference=sourceconf, todate__isnull=False)[0]
+ return "date {0} becomes {1}".format(
+ s.todate, s.todate + xform)
+
+
class BackendConferenceSessionForm(BackendForm):
helplink = 'schedule#sessions'
list_fields = ['title', 'speaker_list', 'status_string', 'starttime', 'track', 'room']
@@ -745,6 +792,75 @@ class TwitterTestForm(django.forms.Form):
recipient = django.forms.CharField(max_length=64)
message = django.forms.CharField(max_length=200)
+#
+# Form for canceling a registration
+#
+class CancelRegistrationForm(django.forms.Form):
+ refund = django.forms.ChoiceField(required=True, label="Method of refund")
+ reason = django.forms.CharField(required=True, max_length=100, label="Reason for cancel",
+ help_text="Copied directly into confirmation emails and refund notices!")
+ confirm = django.forms.BooleanField(help_text="Confirm that you want to cancel this registration!")
+
+ class Methods:
+ NO_INVOICE=-1
+ CANCEL_INVOICE=-2
+ NO_REFUND=-3
+
+ def __init__(self, reg, totalnovat, totalvat, *args, **kwargs):
+ self.reg = reg
+ self.totalnovat = totalnovat
+ self.totalvat = totalvat
+ super(CancelRegistrationForm, self).__init__(*args, **kwargs)
+
+ if reg.payconfirmedat:
+ if reg.payconfirmedby in ("no payment reqd", "Multireg/nopay"):
+ choices = [(self.Methods.NO_INVOICE, 'Registration did not require payment, just cancel'), ]
+ elif reg.payconfirmedby in ("Invoice paid", 'Bulk paid'):
+ choices = [
+ (pattern.id, self.get_text_for_pattern(pattern))
+ for pattern in RefundPattern.objects.filter(conference=self.reg.conference).order_by(F('fromdate').asc(nulls_first=True), 'todate', 'percent')
+ ]
+ choices += [(self.Methods.NO_REFUND, 'Cancel without refund'), ]
+ else:
+ choices = [(self.Methods.NO_REFUND, 'Cancel without refund'), ]
+ else:
+ # Registration not paid yet. Does it have an invoice?
+ if reg.invoice:
+ choices = [(self.Methods.CANCEL_INVOICE, 'Cancel unpaid invoice'), ]
+ elif reg.bulkpayment:
+ # Part of unpaid bulk payment, can't deal with that yet
+ choices = []
+ else:
+ choices = [(self.Methods.NO_INVOICE, 'No invoice created, just cancel'), ]
+
+ self.fields['refund'].choices = [(None, '-- Select method'), ] + choices
+
+ if not 'refund' in self.data:
+ del self.fields['confirm']
+
+ def get_text_for_pattern(self, pattern):
+ # First figure out if this pattern is suggested today
+ today = datetime.date.today()
+ if (pattern.fromdate is None or pattern.fromdate <= today) and \
+ (pattern.todate is None or pattern.todate >= today):
+ suggest = "***"
+ else:
+ suggest = ""
+
+ to_refund = (self.totalnovat * pattern.percent / Decimal(100) - pattern.fees).quantize(Decimal('0.01'))
+ to_refund_vat = (self.totalvat * pattern.percent / Decimal(100) - pattern.fees * self.reg.conference.vat_registrations.vatpercent / Decimal(100)).quantize(Decimal('0.01'))
+
+ return u"{} Refund {}%{} ({}{}{}){}{} {}".format(
+ suggest,
+ pattern.percent,
+ pattern.fees and u' minus {0}{1} in fees'.format(settings.CURRENCY_SYMBOL.decode('utf8'), pattern.fees) or u'',
+ settings.CURRENCY_SYMBOL.decode('utf8'),
+ to_refund,
+ to_refund_vat and u' +{}{} VAT'.format(settings.CURRENCY_SYMBOL.decode('utf8'), to_refund_vat) or '',
+ pattern.fromdate and ' from {0}'.format(pattern.fromdate) or '',
+ pattern.todate and ' until {0}'.format(pattern.todate) or '',
+ suggest,
+ )
#
# Form for sending email
diff --git a/postgresqleu/confreg/backendviews.py b/postgresqleu/confreg/backendviews.py
index 8c23097b..5d865f45 100644
--- a/postgresqleu/confreg/backendviews.py
+++ b/postgresqleu/confreg/backendviews.py
@@ -40,6 +40,7 @@ from backendforms import BackendTshirtSizeForm
from backendforms import BackendNewsForm
from backendforms import TwitterForm, TwitterTestForm
from backendforms import BackendSendEmailForm
+from backendforms import BackendRefundPatternForm
#######################
@@ -137,6 +138,13 @@ def edit_regtypes(request, urlname, rest):
rest)
+def edit_refundpatterns(request, urlname, rest):
+ return backend_list_editor(request,
+ urlname,
+ BackendRefundPatternForm,
+ rest)
+
+
def edit_regdays(request, urlname, rest):
return backend_list_editor(request,
urlname,
diff --git a/postgresqleu/confreg/invoicehandler.py b/postgresqleu/confreg/invoicehandler.py
index 4913f0c5..af6796ed 100644
--- a/postgresqleu/confreg/invoicehandler.py
+++ b/postgresqleu/confreg/invoicehandler.py
@@ -76,16 +76,6 @@ class InvoiceProcessor(object):
reg.vouchercode = ''
reg.save()
- # Process an invoice being refunded. This means we need to unlink
- # it from the registration, and also unconfirm the registration.
- def process_invoice_refund(self, invoice):
- try:
- reg = ConferenceRegistration.objects.get(pk=invoice.processorid)
- except ConferenceRegistration.DoesNotExist:
- raise Exception("Could not find conference registration %s" % invoice.processorid)
-
- cancel_registration(reg)
-
# Return the user to a page showing what happened as a result
# of their payment. In our case, we just return the user directly
# to the registration page.
@@ -97,6 +87,13 @@ class InvoiceProcessor(object):
raise Exception("Could not find conference registration %s" % invoice.processorid)
return "%s/events/%s/register/" % (settings.SITEBASE, reg.conference.urlname)
+ # Admin access to the registration
+ def get_admin_url(self, invoice):
+ try:
+ reg = ConferenceRegistration.objects.get(pk=invoice.processorid)
+ except ConferenceRegistration.DoesNotExist:
+ return None
+ return "/events/admin/{0}/regdashboard/list/{1}/".format(reg.conference.urlname, reg.pk)
class BulkInvoiceProcessor(object):
# Process invoices once they're getting paid
@@ -188,27 +185,6 @@ class BulkInvoiceProcessor(object):
# since it no longer contains anything interesting.
bp.delete()
- # Process an invoice being refunded. Since this is often just a part
- # of a bulk payment, we cannot just cancel all registrations. Instead
- # we'll just generate a notification...
- def process_invoice_refund(self, invoice):
- try:
- bp = BulkPayment.objects.get(pk=invoice.processorid)
- except ConferenceRegistration.DoesNotExist:
- raise Exception("Could not find bulk payment %s" % invoice.processor)
- if not bp.paidat:
- raise Exception("Bulk registration not paid - things are out of sync")
-
- send_simple_mail(bp.conference.contactaddr,
- bp.conference.contactaddr,
- 'Bulk invoice refunded',
- u"The bulk payment with id {0} has been refunded.\nNote that the registrations on this bulk invoice has\nNOT been canceled!!!\n\nThis needs to be processed manually since it may be a partial refund.\n\nThe following registrations are attached:\n\n{1}\n".format(
- bp.id,
- u"\n".join([u'* {0}'.format(r.fullname) for r in bp.conferenceregistration_set.all()]),
- ),
- sendername=bp.conference.conferencename,
- )
-
# Return the user to a page showing what happened as a result
# of their payment. In our case, we just return the user directly
# to the bulk payment page.
@@ -219,6 +195,14 @@ class BulkInvoiceProcessor(object):
raise Exception("Could not find bulk payment %s" % invoice.processor)
return "%s/events/%s/bulkpay/%s/" % (settings.SITEBASE, bp.conference.urlname, invoice.processorid)
+ # Admin access to the bulk payment we just send to the dashboard
+ def get_admin_url(self, invoice):
+ try:
+ bp = BulkPayment.objects.get(pk=invoice.processorid)
+ except ConferenceRegistration.DoesNotExist:
+ return None
+ return "/events/admin/{0}/regdashboard/".format(bp.conference.urlname)
+
class AddonInvoiceProcessor(object):
can_refund = False
@@ -267,3 +251,11 @@ class AddonInvoiceProcessor(object):
raise Exception("Could not find additional options order %s!" % invoice.processorid)
return "%s/events/%s/register/" % (settings.SITEBASE, order.reg.conference.urlname)
+
+ # Admin access to the registration
+ def get_admin_url(self, invoice):
+ try:
+ order = PendingAdditionalOrder.objects.get(pk=invoice.processorid)
+ except PendingAdditionalOrder.DoesNotExist:
+ return None
+ return "/events/admin/{0}/regdashboard/list/{1}/".format(order.reg.conference.urlname, order.reg.pk)
diff --git a/postgresqleu/confreg/migrations/0040_refund_patterns.py b/postgresqleu/confreg/migrations/0040_refund_patterns.py
new file mode 100644
index 00000000..e5285cc2
--- /dev/null
+++ b/postgresqleu/confreg/migrations/0040_refund_patterns.py
@@ -0,0 +1,28 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.10 on 2018-12-27 16:44
+from __future__ import unicode_literals
+
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('confreg', '0039_tickets'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='RefundPattern',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('percent', models.IntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)], verbose_name=b'Percent to refund')),
+ ('fees', models.IntegerField(help_text=b'This amount will be deducted from the calculated refund amount', verbose_name=b'Fees not to refund')),
+ ('fromdate', models.DateField(help_text=b'Suggest for refunds starting from this date', null=True, blank=True, verbose_name=b'From date')),
+ ('todate', models.DateField(help_text=b'Suggest for refunds until this date', null=True, blank=True, verbose_name=b'To date')),
+ ('conference', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='confreg.Conference')),
+ ],
+ ),
+ ]
diff --git a/postgresqleu/confreg/models.py b/postgresqleu/confreg/models.py
index 10e81501..428262c6 100644
--- a/postgresqleu/confreg/models.py
+++ b/postgresqleu/confreg/models.py
@@ -6,7 +6,7 @@ from django.db.models.expressions import F
from django.contrib.auth.models import User
from django.conf import settings
from django.core.exceptions import ValidationError
-from django.core.validators import MinValueValidator
+from django.core.validators import MinValueValidator, MaxValueValidator
from django.utils.dateformat import DateFormat
from django.contrib.postgres.fields import DateTimeRangeField
@@ -1013,6 +1013,14 @@ class PendingAdditionalOrder(models.Model):
return u"%s" % (self.reg, )
+class RefundPattern(models.Model):
+ conference = models.ForeignKey(Conference, null=False, blank=False, on_delete=models.CASCADE)
+ percent = models.IntegerField(null=False, verbose_name="Percent to refund", validators=[MinValueValidator(1), MaxValueValidator(100)])
+ fees = models.IntegerField(null=False, verbose_name="Fees not to refund", help_text="This amount will be deducted from the calculated refund amount")
+ fromdate = models.DateField(null=True, blank=True, verbose_name="From date", help_text="Suggest for refunds starting from this date")
+ todate = models.DateField(null=True, blank=True, verbose_name="To date", help_text="Suggest for refunds until this date")
+
+
class AggregatedTshirtSizes(models.Model):
conference = models.ForeignKey(Conference, null=False, blank=False, on_delete=models.CASCADE)
size = models.ForeignKey(ShirtSize, null=False, blank=False, on_delete=models.CASCADE)
diff --git a/postgresqleu/confreg/util.py b/postgresqleu/confreg/util.py
index 90af5a1f..b58ac245 100644
--- a/postgresqleu/confreg/util.py
+++ b/postgresqleu/confreg/util.py
@@ -6,6 +6,7 @@ from django.core.exceptions import PermissionDenied
from datetime import datetime, date, timedelta
import urllib
from io import BytesIO
+import re
from postgresqleu.mailqueue.util import send_simple_mail, send_template_mail
from postgresqleu.util.middleware import RedirectException
@@ -96,6 +97,36 @@ def invoicerows_for_registration(reg, update_used_vouchers):
raise InvoicerowsException("Invalid voucher code")
return r
+def attendee_cost_from_bulk_payment(reg):
+ re_email_dash = re.compile("^[^\s]+@[^\s]+ - [^\s]")
+ if not reg.bulkpayment:
+ raise Exception("Not called with bulk payment!")
+
+ # We need to find the individual rows and sum it up, since it's possible that we have been
+ # using discount codes for example.
+ # We have no better key to work with than the email address...
+ found = False
+ totalnovat = totalvat = 0
+ for r in reg.bulkpayment.invoice.invoicerow_set.all().order_by('id'):
+ if r.rowtext.startswith(reg.email + ' - '):
+ # Found the beginning!
+ if found:
+ raise Exception("Found the same registration more than once!")
+ found = True
+ totalnovat = r.totalrow
+ totalvat = r.totalvat
+ elif r.rowtext.startswith(" "):
+ # Something to do with this reg
+ if found:
+ totalnovat += r.totalrow
+ totalvat += r.totalvat
+ elif re_email_dash.match(r.rowtext):
+ # Matched a different reg
+ found = False
+ else:
+ raise Exception("Unknown invoice row '%s'" % r.rowtext)
+
+ return (totalnovat, totalvat)
def notify_reg_confirmed(reg, updatewaitlist=True):
# This one was off the waitlist, so generate a history entry
diff --git a/postgresqleu/confreg/views.py b/postgresqleu/confreg/views.py
index 275fe61d..6be7da82 100644
--- a/postgresqleu/confreg/views.py
+++ b/postgresqleu/confreg/views.py
@@ -19,7 +19,7 @@ from models import Conference, ConferenceRegistration, ConferenceSession, Confer
from models import ConferenceSessionSlides, ConferenceSessionVote, GlobalOptOut
from models import ConferenceSessionFeedback, Speaker, Speaker_Photo
from models import ConferenceFeedbackQuestion, ConferenceFeedbackAnswer
-from models import RegistrationType, PrepaidVoucher, PrepaidBatch
+from models import RegistrationType, PrepaidVoucher, PrepaidBatch, RefundPattern
from models import BulkPayment, Room, Track, ConferenceSessionScheduleSlot
from models import AttendeeMail, ConferenceAdditionalOption
from models import PendingAdditionalOrder
@@ -36,12 +36,14 @@ from forms import NewMultiRegForm, MultiRegInvoiceForm
from forms import SessionSlidesUrlForm, SessionSlidesFileForm
from util import invoicerows_for_registration, notify_reg_confirmed, InvoicerowsException
from util import get_invoice_autocancel, cancel_registration
+from util import attendee_cost_from_bulk_payment
from models import get_status_string
from regtypes import confirm_special_reg_type, validate_special_reg_type
from jinjafunc import render_jinja_conference_response, JINJA_TEMPLATE_ROOT
from jinjapdf import render_jinja_ticket
from backendviews import get_authenticated_conference
+from backendforms import CancelRegistrationForm
from postgresqleu.util.decorators import superuser_required
from postgresqleu.util.random import generate_random_token
@@ -2820,23 +2822,103 @@ def admin_registration_single(request, urlname, regid):
@transaction.atomic
def admin_registration_cancel(request, urlname, regid):
conference = get_authenticated_conference(request, urlname)
-
reg = get_object_or_404(ConferenceRegistration, id=regid, conference=conference)
- if request.method == 'POST' and request.POST.get('docancel') == '1':
- name = reg.fullname
- is_unconfirmed = (reg.payconfirmedat is None)
- cancel_registration(reg, is_unconfirmed)
- return render(request, 'confreg/admin_registration_cancel_confirm.html', {
- 'conference': conference,
- 'name': name,
- })
+ if reg.pendingadditionalorder_set.exists():
+ messages.error(request, "Sorry, can't refund invoices that have post-purchased additional options yet")
+ return HttpResponseRedirect("../")
+
+ # Figure out the total cost paid
+ if reg.invoice:
+ totalnovat = reg.invoice.total_amount - reg.invoice.total_vat
+ totalvat = reg.invoice.total_vat
+ elif reg.bulkpayment:
+ (totalnovat, totalvat) = attendee_cost_from_bulk_payment(reg)
else:
- return render(request, 'confreg/admin_registration_cancel.html', {
- 'conference': conference,
- 'reg': reg,
- 'helplink': 'waitlist',
- })
+ totalnovat = totalvat = 0
+
+ if request.method == 'POST':
+ form = CancelRegistrationForm(reg, totalnovat, totalvat, data=request.POST)
+ if form.is_valid():
+ manager = InvoiceManager()
+ method = int(form.cleaned_data['refund'])
+ reason = form.cleaned_data['reason']
+ if method == form.Methods.NO_INVOICE:
+ # Registration that did not have an invoice.
+ # This is either a confirmed registration with no payment required,
+ # or an unconfirmed registration.
+ cancel_registration(reg, reg.payconfirmedat is not None)
+ elif method == form.Methods.CANCEL_INVOICE:
+ # An invoice exists and should be canceled. Since it's not paid yet,
+ # this is easy.
+ manager.cancel_invoice(reg.invoice, reason)
+ elif method == form.Methods.NO_REFUND:
+ # An invoice may exist, but in this case we don't want to provide
+ # a refund. This can only happen for registrations that are actually
+ # confirmed.
+ if reg.invoice:
+ reg.invoice = None
+ reg.save()
+ elif reg.bulkpayment:
+ reg.bulkpayment = None
+ reg.save()
+ else:
+ raise Exception("Can't cancel this without refund")
+ cancel_registration(reg)
+ elif method >= 0:
+ # Refund using a pattern!
+ try:
+ pattern = RefundPattern.objects.get(conference=conference, pk=method)
+ except RefundPattern.DoesNotExist:
+ messages.error(request, "Can't re-find registration pattern")
+ return HttpResponseRedirect(".")
+ # Calculate amount to refund
+ to_refund = (totalnovat * pattern.percent / Decimal(100) - pattern.fees).quantize(Decimal('0.01'))
+ to_refund_vat = (totalvat * pattern.percent / Decimal(100) - pattern.fees * conference.vat_registrations.vatpercent / Decimal(100)).quantize(Decimal('0.01'))
+ if reg.invoice:
+ invoice = reg.invoice
+ elif reg.bulkpayment:
+ invoice = reg.bulkpayment.invoice
+ else:
+ messages.error(request, "Can't find which invoice to refund")
+ return HttpResponseRedirect(".")
+
+ if to_refund < 0 or to_refund_vat < 0:
+ messages.error(request, "Selected pattern would lead to negative refunding, can't refund.")
+ elif to_refund > invoice.total_refunds['remaining']['amount']:
+ messages.error(request, "Attempt to refund {0}, which is more than remaing {1}".format(to_refund, invoice.total_refunds['remaining']['amount']))
+ elif to_refund_vat > invoice.total_refunds['remaining']['vatamount']:
+ messages.error(request, "Attempt to refund VAT {0}, which is more than remaining {1}".format(to_refund_vat, invoice.total_refunds['remaining']['vatamount']))
+ else:
+ # OK, looks good. Start by refunding the invoice.
+ manager.refund_invoice(invoice, reason, to_refund, to_refund_vat, conference.vat_registrations)
+ # Then cancel the actual registration
+ cancel_registration(reg)
+
+ messages.info(request, "Invoice refunded and registration canceled.")
+ return HttpResponseRedirect("../../")
+ return HttpResponseRedirect(".")
+ else:
+ raise Exception("Don't know how to cancel like that")
+ messages.info(request, "Registration canceled")
+ return HttpResponseRedirect("../../")
+ else:
+ form = CancelRegistrationForm(reg, totalnovat, totalvat)
+
+ return render(request, 'confreg/admin_registration_cancel.html', {
+ 'conference': conference,
+ 'reg': reg,
+ 'form': form,
+ 'totalnovat': totalnovat,
+ 'totalvat': totalvat,
+ 'totalwithvat': totalnovat + totalvat,
+ 'helplink': 'registrations',
+ 'breadcrumbs': (
+ ('/events/admin/{0}/regdashboard/'.format(urlname), 'Registration dashboard'),
+ ('/events/admin/{0}/regdashboard/list/'.format(urlname), 'Registration list'),
+ ('/events/admin/{0}/regdashboard/list/{1}/'.format(urlname, reg.id), reg.fullname),
+ ),
+ })
@transaction.atomic
diff --git a/postgresqleu/confsponsor/invoicehandler.py b/postgresqleu/confsponsor/invoicehandler.py
index 0ec938fd..25682fe6 100644
--- a/postgresqleu/confsponsor/invoicehandler.py
+++ b/postgresqleu/confsponsor/invoicehandler.py
@@ -74,32 +74,6 @@ class InvoiceProcessor(object):
sponsor.invoice = None
sponsor.save()
- # An invoice was refunded. Actually undoing everything from here
- # is very complicated (e.g. what do we do with attendee vouchers
- # that have already been used?). Because of that, punt that whole
- # thing and just send an email about it instead, letting the
- # operator deal with it.
- # All we'll do is unconfirm the sponsor itself.
- def process_invoice_refund(self, invoice):
- try:
- sponsor = Sponsor.objects.get(pk=invoice.processorid)
- except Sponsor.DoesNotExist:
- raise Exception("Could not find conference sponsorship %s" % invoice.processorid)
-
- sponsor.confirmed = False
- sponsor.confirmedat = None
- sponsor.confirmedby = "Unconfirmed by refund"
- sponsor.save()
-
- msgtxt = "The invoice for sponsors {0} has been refunded.\n\nYou need to manually 'undo' any benefits\nthat this sponsor has received, in case that is intended.\n\n".format(sponsor)
-
- for a in sponsor.conference.sponsoraddr, settings.INVOICE_SENDER_EMAIL:
- send_simple_mail(sponsor.conference.sponsoraddr,
- a,
- u"Sponsor {0} refunded!".format(sponsor),
- msgtxt,
- sendername=sponsor.conference.conferencename)
-
# Return the user to the sponsor page if they have paid.
def get_return_url(self, invoice):
try:
@@ -108,6 +82,12 @@ class InvoiceProcessor(object):
raise Exception("Could not find conference sponsorship %s" % invoice.processorid)
return "%s/events/sponsor/%s/" % (settings.SITEBASE, sponsor.id)
+ def get_admin_url(self, invoice):
+ try:
+ sponsor = Sponsor.objects.get(pk=invoice.processorid)
+ except Sponsor.DoesNotExist:
+ return None
+ return "/events/sponsor/admin/{0}/{1}/".format(sponsor.conference.urlname, sponsor.pk)
def get_sponsor_invoice_address(name, invoiceaddr, vatnumber):
if settings.EU_VAT and vatnumber:
@@ -250,6 +230,13 @@ class VoucherInvoiceProcessor(object):
raise Exception("Could not find voucher order %s" % invoice.processorid)
return "%s/events/sponsor/%s/" % (settings.SITEBASE, pv.sponsor.id)
+ def get_admin_url(self, invoice):
+ try:
+ pv = PurchasedVoucher.objects.get(pk=invoice.processorid)
+ except PurchasedVoucher.DoesNotExist:
+ return None
+ return "/events/sponsor/admin/{0}/{1}/".format(pv.sponsor.conference.urlname, pv.sponsor.id)
+
# Generate an invoice for prepaid vouchers
def create_voucher_invoice(sponsor, user, rt, num):
diff --git a/postgresqleu/invoices/admin.py b/postgresqleu/invoices/admin.py
index 3a3151b0..fe312910 100644
--- a/postgresqleu/invoices/admin.py
+++ b/postgresqleu/invoices/admin.py
@@ -59,7 +59,6 @@ class InvoiceAdmin(admin.ModelAdmin):
form = InvoiceAdminForm
exclude = ['pdf_invoice', 'pdf_receipt', ]
filter_horizontal = ['allowedmethods', ]
- readonly_fields = ['refund', ]
class InvoiceLogAdmin(admin.ModelAdmin):
diff --git a/postgresqleu/invoices/forms.py b/postgresqleu/invoices/forms.py
index 34d2caea..18603a77 100644
--- a/postgresqleu/invoices/forms.py
+++ b/postgresqleu/invoices/forms.py
@@ -1,4 +1,5 @@
from django import forms
+from django.core.validators import MinValueValidator, MaxValueValidator
from django.forms import ValidationError
from django.forms import widgets
from django.contrib.auth.models import User
@@ -107,16 +108,17 @@ class InvoiceRowForm(forms.ModelForm):
class RefundForm(forms.Form):
- amount = forms.DecimalField(required=True)
- vatamount = forms.DecimalField(required=True)
+ amount = forms.DecimalField(required=True, label="Amount ex VAT", validators=[MinValueValidator(1), ])
vatrate = forms.ModelChoiceField(queryset=VatRate.objects.all(), required=False)
- reason = forms.CharField(max_length=100, required=True)
+ reason = forms.CharField(max_length=100, required=True, help_text="Note! Included in communication to invoice recipient!")
confirm = forms.BooleanField()
def __init__(self, invoice, *args, **kwargs):
super(RefundForm, self).__init__(*args, **kwargs)
self.invoice = invoice
+ self.fields['amount'].validators.append(MaxValueValidator(invoice.total_refunds['remaining']['amount']))
+
if self.data and 'amount' in self.data and 'reason' in self.data:
if invoice.can_autorefund:
self.fields['confirm'].help_text = "Check this box to confirm that you want to generate an <b>automatic</b> refund of this invoice."
@@ -124,41 +126,3 @@ class RefundForm(forms.Form):
self.fields['confirm'].help_text = "check this box to confirm that you have <b>already</b> manually refunded this invoice."
else:
del self.fields['confirm']
-
- def clean_amount(self):
- errstr = "Amount must be a decimal between 1 and {0}".format(self.invoice.total_amount - self.invoice.total_vat)
-
- try:
- amount = Decimal(self.cleaned_data['amount'])
- if amount < 1 or amount > self.invoice.total_amount - self.invoice.total_vat:
- raise ValidationError(errstr)
- if amount.as_tuple().exponent > 0 or amount.as_tuple().exponent < -2:
- raise ValidationError("Maximum two decimal digits supported")
- return self.cleaned_data['amount']
- except ValidationError:
- raise
- except:
- raise ValidationError(errstr)
-
- def clean_vatamount(self):
- errstr = "VAT Amount must be a decimal between 0 and {0}".format(self.invoice.total_vat)
-
- try:
- amount = Decimal(self.cleaned_data['vatamount'])
- if amount < 0 or amount > self.invoice.total_vat:
- raise ValidationError(errstr)
- if amount.as_tuple().exponent > 0 or amount.as_tuple().exponent < -2:
- raise ValidationError("Maximum two decimal digits supported")
- return self.cleaned_data['vatamount']
- except ValidationError:
- raise
- except:
- raise ValidationError(errstr)
-
- def clean(self):
- data = super(RefundForm, self).clean()
-
- if 'vatamount' in data and Decimal(data['vatamount']) > 0:
- if not data.get('vatrate', 0):
- raise ValidationError({'vatrate': ['When VAT amount is specified, at VAT rate must be selected']})
- return data
diff --git a/postgresqleu/invoices/migrations/0009_invoices_new_refunds.py b/postgresqleu/invoices/migrations/0009_invoices_new_refunds.py
new file mode 100644
index 00000000..c3a8406e
--- /dev/null
+++ b/postgresqleu/invoices/migrations/0009_invoices_new_refunds.py
@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.10 on 2018-12-27 12:38
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('invoices', '0008_invoice_bcc_list'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='invoicerefund',
+ name='invoice',
+ field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='invoices.Invoice'),
+ ),
+ migrations.RunSQL("UPDATE invoices_invoicerefund SET invoice_id=invoices_invoice.id FROM invoices_invoice WHERE invoices_invoice.refund_id=invoices_invoicerefund.id"),
+ migrations.AlterField(
+ model_name='invoicerefund',
+ name='invoice',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='invoices.Invoice'),
+ ),
+ migrations.RemoveField(
+ model_name='invoice',
+ name='refund',
+ ),
+ migrations.AlterModelOptions(
+ name='invoicerefund',
+ options={'ordering': ('id',)},
+ )
+ ]
diff --git a/postgresqleu/invoices/models.py b/postgresqleu/invoices/models.py
index 46dc1352..dc2cdf05 100644
--- a/postgresqleu/invoices/models.py
+++ b/postgresqleu/invoices/models.py
@@ -41,6 +41,7 @@ class InvoicePaymentMethod(models.Model):
class InvoiceRefund(models.Model):
+ invoice = models.ForeignKey("Invoice", null=False, blank=False)
reason = models.CharField(max_length=500, null=False, blank=True, default='', help_text="Reason for refunding of invoice")
amount = models.DecimalField(max_digits=10, decimal_places=2, null=False)
@@ -55,6 +56,9 @@ class InvoiceRefund(models.Model):
refund_pdf = models.TextField(blank=True, null=False)
+ class Meta:
+ ordering = ('id', )
+
@property
def fullamount(self):
return self.amount + self.vatamount
@@ -90,8 +94,6 @@ class Invoice(models.Model):
deleted = models.BooleanField(null=False, blank=False, default=False, help_text="This invoice has been deleted")
deletion_reason = models.CharField(max_length=500, null=False, blank=True, default='', help_text="Reason for deletion of invoice")
- refund = models.OneToOneField(InvoiceRefund, null=True, blank=True, on_delete=models.SET_NULL)
-
# base64 encoded version of the PDF invoice
pdf_invoice = models.TextField(blank=True, null=False)
@@ -174,6 +176,18 @@ class Invoice(models.Model):
return PaymentMethodWrapper(self.paidusing, self).autorefund()
@property
+ def total_refunds(self):
+ agg = self.invoicerefund_set.all().aggregate(models.Sum('amount'), models.Sum('vatamount'))
+ return {
+ 'amount': agg['amount__sum'] or 0,
+ 'vatamount': agg['vatamount__sum'] or 0,
+ 'remaining': {
+ 'amount': self.total_amount - self.total_vat - (agg['amount__sum'] or 0),
+ 'vatamount': self.total_vat - (agg['vatamount__sum'] or 0),
+ }
+ }
+
+ @property
def payment_method_description(self):
if not self.paidat:
return "not paid"
@@ -185,8 +199,6 @@ class Invoice(models.Model):
def statusstring(self):
if self.deleted:
return "canceled"
- elif self.refund:
- return "refunded"
elif self.paidat:
return "paid"
if self.finalized:
diff --git a/postgresqleu/invoices/util.py b/postgresqleu/invoices/util.py
index e0bd18ea..a9c56c22 100644
--- a/postgresqleu/invoices/util.py
+++ b/postgresqleu/invoices/util.py
@@ -120,18 +120,22 @@ class InvoiceWrapper(object):
return pdfinvoice.save().getvalue()
- def render_pdf_refund(self):
+ def render_pdf_refund(self, refund):
(modname, classname) = settings.REFUND_PDF_BUILDER.rsplit('.', 1)
PDFRefund = getattr(importlib.import_module(modname), classname)
pdfnote = PDFRefund("%s\n%s" % (self.invoice.recipient_name, self.invoice.recipient_address),
self.invoice.invoicedate,
- self.invoice.refund.completed,
+ refund.completed,
self.invoice.id,
self.invoice.total_amount - self.invoice.total_vat,
self.invoice.total_vat,
- self.invoice.refund.amount,
- self.invoice.refund.vatamount,
+ refund.amount,
+ refund.vatamount,
self.used_payment_details(),
+ refund.id,
+ refund.reason,
+ self.invoice.total_refunds['amount']-refund.amount,
+ self.invoice.total_refunds['vatamount']-refund.vatamount,
)
return pdfnote.save().getvalue()
@@ -185,22 +189,26 @@ class InvoiceWrapper(object):
)
InvoiceHistory(invoice=self.invoice, txt='Sent cancellation').save()
- def email_refund_initiated(self):
+ def email_refund_initiated(self, refund):
self._email_something('invoice_refund_initiated.txt',
'%s #%s - refund initiated' % (settings.INVOICE_TITLE_PREFIX, self.invoice.id),
- bcc=True)
+ bcc=True,
+ extracontext={'refund': refund}
+ )
InvoiceHistory(invoice=self.invoice, txt='Sent refund initiated notice').save()
- def email_refund_sent(self):
+ def email_refund_sent(self, refund):
# Generate the refund notice so we have something to send
- self.invoice.refund.refund_pdf = base64.b64encode(self.render_pdf_refund())
- self.invoice.refund.save()
+ refund.refund_pdf = base64.b64encode(self.render_pdf_refund(refund))
+ refund.save()
self._email_something('invoice_refund.txt',
'%s #%s - refunded' % (settings.INVOICE_TITLE_PREFIX, self.invoice.id),
'{0}_refund_{1}.pdf'.format(settings.INVOICE_FILENAME_PREFIX, self.invoice.id),
- self.invoice.refund.refund_pdf,
- bcc=True)
+ refund.refund_pdf,
+ bcc=True,
+ extracontext={'refund': refund}
+ )
InvoiceHistory(invoice=self.invoice, txt='Sent refund notice').save()
def _email_something(self, template_name, mail_subject, pdfname=None, pdfcontents=None, bcc=False, extracontext=None):
@@ -471,21 +479,9 @@ class InvoiceManager(object):
def refund_invoice(self, invoice, reason, amount, vatamount, vatrate):
# Initiate a refund of an invoice if there is a payment provider that supports it.
# Otherwise, flag the invoice as refunded, and assume the user took care of it manually.
- if invoice.refund:
- raise Exception("This invoice has already been refunded")
-
- # If this invoice has a processor, we need to start by calling it
- processor = self.get_invoice_processor(invoice)
- if processor and getattr(processor, 'can_refund', True):
- try:
- r = processor.process_invoice_refund(invoice)
- except Exception, ex:
- raise Exception("Failed to run invoice processor '%s': %s" % (invoice.processor, ex))
- r = InvoiceRefund(reason=reason, amount=amount, vatamount=vatamount, vatrate=vatrate)
+ r = InvoiceRefund(invoice=invoice, reason=reason, amount=amount, vatamount=vatamount, vatrate=vatrate)
r.save()
- invoice.refund = r
- invoice.save()
InvoiceHistory(invoice=invoice,
txt='Registered refund of {0}{1}'.format(settings.CURRENCY_SYMBOL, amount + vatamount)).save()
@@ -493,7 +489,7 @@ class InvoiceManager(object):
wrapper = InvoiceWrapper(invoice)
if invoice.can_autorefund:
# Send an initial notice to the user.
- wrapper.email_refund_initiated()
+ wrapper.email_refund_initiated(r)
# Accounting record is created when we send the API call to the
# provider.
@@ -519,7 +515,7 @@ class InvoiceManager(object):
]
if vatamount:
accrows.append(
- (invoice.refund.vatrate.vataccount.num, accountingtxt, vatamount, None),
+ (r.vatrate.vataccount.num, accountingtxt, vatamount, None),
)
urls = ['%s/invoices/%s/' % (settings.SITEBASE, invoice.pk), ]
@@ -528,7 +524,7 @@ class InvoiceManager(object):
InvoiceHistory(invoice=invoice,
txt='Flagged refund of {0}{1}'.format(settings.CURRENCY_SYMBOL, amount + vatamount)).save()
- wrapper.email_refund_sent()
+ wrapper.email_refund_sent(r)
InvoiceLog(timestamp=datetime.now(),
message=u"Flagged invoice {0} as refunded by {1}{2}: {3}".format(invoice.id, settings.CURRENCY_SYMBOL.decode('utf8'), amount + vatamount, reason),
).save()
@@ -585,9 +581,9 @@ class InvoiceManager(object):
refund.save()
wrapper = InvoiceWrapper(invoice)
- wrapper.email_refund_sent()
+ wrapper.email_refund_sent(refund)
- InvoiceHistory(invoice=invoice, txt='Completed refund').save()
+ InvoiceHistory(invoice=invoice, txt='Completed refund {0}'.format(refund.id)).save()
# This creates a complete invoice, and finalizes it
def create_invoice(self,
@@ -658,13 +654,13 @@ class TestProcessor(object):
def process_invoice_cancellation(self, invoice):
raise Exception("This processor can't cancel invoices.")
- def process_invoice_refund(self, invoice):
- raise Exception("This processor can't refund invoices.")
-
def get_return_url(self, invoice):
print "Trying to get the return url, but I can't!"
return "http://unknown.postgresql.eu/"
+ def get_admin_url(self, invoice):
+ return None
+
# Calculate the number of workdays between two datetimes.
def diff_workdays(start, end):
diff --git a/postgresqleu/invoices/views.py b/postgresqleu/invoices/views.py
index 13db65b9..9d86cdb3 100644
--- a/postgresqleu/invoices/views.py
+++ b/postgresqleu/invoices/views.py
@@ -16,6 +16,7 @@ from decimal import Decimal
from postgresqleu.util.decorators import user_passes_test_or_error
from models import Invoice, InvoiceRow, InvoiceHistory, InvoicePaymentMethod, VatRate
+from models import InvoiceRefund
from forms import InvoiceForm, InvoiceRowForm, RefundForm
from util import InvoiceWrapper, InvoiceManager, InvoicePresentationWrapper
@@ -23,13 +24,13 @@ from util import InvoiceWrapper, InvoiceManager, InvoicePresentationWrapper
@login_required
@user_passes_test_or_error(lambda u: u.has_module_perms('invoices'))
def paid(request):
- return _homeview(request, Invoice.objects.filter(paidat__isnull=False, deleted=False, refund__isnull=True, finalized=True), paid=True)
+ return _homeview(request, Invoice.objects.filter(paidat__isnull=False, deleted=False, finalized=True), paid=True)
@login_required
@user_passes_test_or_error(lambda u: u.has_module_perms('invoices'))
def unpaid(request):
- return _homeview(request, Invoice.objects.filter(paidat=None, deleted=False, finalized=True, refund__isnull=True), unpaid=True)
+ return _homeview(request, Invoice.objects.filter(paidat=None, deleted=False, finalized=True), unpaid=True)
@login_required
@@ -44,14 +45,8 @@ def deleted(request):
return _homeview(request, Invoice.objects.filter(deleted=True), deleted=True)
-@login_required
-@user_passes_test_or_error(lambda u: u.has_module_perms('invoices'))
-def refunded(request):
- return _homeview(request, Invoice.objects.filter(refund__isnull=False), refunded=True)
-
-
# Not a view, just a utility function, thus no separate permissions check
-def _homeview(request, invoice_objects, unpaid=False, pending=False, deleted=False, refunded=False, paid=False, searchterm=None):
+def _homeview(request, invoice_objects, unpaid=False, pending=False, deleted=False, paid=False, searchterm=None):
# Render a list of all invoices
paginator = Paginator(invoice_objects, 50)
@@ -84,7 +79,6 @@ def _homeview(request, invoice_objects, unpaid=False, pending=False, deleted=Fal
'unpaid': unpaid,
'pending': pending,
'deleted': deleted,
- 'refunded': refunded,
'has_pending': has_pending,
'has_unpaid': has_unpaid,
'searchterm': searchterm,
@@ -218,10 +212,17 @@ def oneinvoice(request, invoicenum):
)
formset = InvoiceRowInlineFormset(instance=invoice)
+ if invoice.processor:
+ manager = InvoiceManager()
+ processor = manager.get_invoice_processor(invoice)
+ adminurl = processor.get_admin_url(invoice)
+ else:
+ adminurl = None
return render(request, 'invoices/invoiceform.html', {
'form': form,
'formset': formset,
'invoice': invoice,
+ 'adminurl': adminurl,
'currency_symbol': settings.CURRENCY_SYMBOL,
'vatrates': VatRate.objects.all(),
'breadcrumbs': [('/invoiceadmin/', 'Invoices'), ],
@@ -310,24 +311,32 @@ def extend_cancel(request, invoicenum):
@transaction.atomic
def refundinvoice(request, invoicenum):
invoice = get_object_or_404(Invoice, pk=invoicenum)
- if invoice.refund:
- raise Exception('This invoice has already been refunded')
if request.method == 'POST':
form = RefundForm(data=request.POST, invoice=invoice)
if form.is_valid():
+ # Do some sanity checking
+ if form.cleaned_data['vatrate']:
+ vatamount = (Decimal(form.cleaned_data['amount']) * form.cleaned_data['vatrate'].vatpercent / Decimal(100)).quantize(Decimal('0.01'))
+ if vatamount > invoice.total_refunds['remaining']['vatamount']:
+ messages.error(request, "Unable to refund, VAT amount mismatch!")
+ return HttpResponseRedirect('.')
+ else:
+ vatamount = 0
+
mgr = InvoiceManager()
r = mgr.refund_invoice(invoice,
form.cleaned_data['reason'],
Decimal(form.cleaned_data['amount']),
- Decimal(form.cleaned_data['vatamount']),
+ vatamount,
form.cleaned_data['vatrate'],
)
- return render(request, 'invoices/refundform_complete.html', {
- 'invoice': invoice,
- 'refund': r,
- 'breadcrumbs': [('/invoiceadmin/', 'Invoices'), ('/invoiceadmin/{0}/'.format(invoice.pk), 'Invoice #{0}'.format(invoice.pk)), ],
- })
+ if invoice.can_autorefund:
+ messages.info(request, "Refund initiated.")
+ else:
+ messages.info(request, "Refund flagged.")
+
+ return HttpResponseRedirect(".")
else:
form = RefundForm(invoice=invoice)
@@ -337,9 +346,6 @@ def refundinvoice(request, invoicenum):
return render(request, 'invoices/refundform.html', {
'form': form,
'invoice': invoice,
- 'currency_symbol': settings.CURRENCY_SYMBOL,
- 'samevat': (vinfo['n'] <= 1),
- 'globalvat': vinfo['v'],
'breadcrumbs': [('/invoiceadmin/', 'Invoices'), ('/invoiceadmin/{0}/'.format(invoice.pk), 'Invoice #{0}'.format(invoice.pk)), ],
})
@@ -448,20 +454,22 @@ def viewreceipt_secret(request, invoiceid, invoicesecret):
@login_required
-def viewrefundnote(request, invoiceid):
+def viewrefundnote(request, invoiceid, refundid):
invoice = get_object_or_404(Invoice, pk=invoiceid)
if not (request.user.has_module_perms('invoices') or invoice.recipient_user == request.user):
return HttpResponseForbidden("Access denied")
+ refund = get_object_or_404(InvoiceRefund, invoice=invoiceid, pk=refundid)
r = HttpResponse(content_type='application/pdf')
- r.write(base64.b64decode(invoice.refund.refund_pdf))
+ r.write(base64.b64decode(refund.refund_pdf))
return r
-def viewrefundnote_secret(request, invoiceid, invoicesecret):
+def viewrefundnote_secret(request, invoiceid, invoicesecret, refundid):
invoice = get_object_or_404(Invoice, pk=invoiceid, recipient_secret=invoicesecret)
+ refund = get_object_or_404(InvoiceRefund, invoice=invoice, pk=refundid)
r = HttpResponse(content_type='application/pdf')
- r.write(base64.b64decode(invoice.refund.refund_pdf))
+ r.write(base64.b64decode(refund.refund_pdf))
return r
diff --git a/postgresqleu/membership/invoicehandler.py b/postgresqleu/membership/invoicehandler.py
index 7264c326..2e32bbf4 100644
--- a/postgresqleu/membership/invoicehandler.py
+++ b/postgresqleu/membership/invoicehandler.py
@@ -60,3 +60,10 @@ class InvoiceProcessor(object):
# to the membership page.
def get_return_url(self, invoice):
return "%s/membership/" % settings.SITEBASE
+
+ def get_admin_url(self, invoice):
+ try:
+ member = Member.objects.get(pk=invoice.processorid)
+ except Member.DoesNotExist:
+ return None
+ return '/admin/membership/members/{0}/'.format(member.pk)
diff --git a/postgresqleu/urls.py b/postgresqleu/urls.py
index 9ed985d7..ef188581 100644
--- a/postgresqleu/urls.py
+++ b/postgresqleu/urls.py
@@ -186,6 +186,7 @@ urlpatterns.extend([
url(r'^events/admin/(\w+)/addopts/(.*/)?$', postgresqleu.confreg.backendviews.edit_additionaloptions),
url(r'^events/admin/(\w+)/tracks/(.*/)?$', postgresqleu.confreg.backendviews.edit_tracks),
url(r'^events/admin/(\w+)/rooms/(.*/)?$', postgresqleu.confreg.backendviews.edit_rooms),
+ url(r'^events/admin/(\w+)/refundpatterns/(.*/)?$', postgresqleu.confreg.backendviews.edit_refundpatterns),
url(r'^events/admin/(\w+)/sessions/sendmail/$', postgresqleu.confreg.backendviews.conference_session_send_email),
url(r'^events/admin/(\w+)/sessions/(.*/)?$', postgresqleu.confreg.backendviews.edit_sessions),
url(r'^events/admin/(\w+)/scheduleslots/(.*/)?$', postgresqleu.confreg.backendviews.edit_scheduleslots),
@@ -228,7 +229,6 @@ urlpatterns.extend([
url(r'^invoiceadmin/paid/$', postgresqleu.invoices.views.paid),
url(r'^invoiceadmin/pending/$', postgresqleu.invoices.views.pending),
url(r'^invoiceadmin/deleted/$', postgresqleu.invoices.views.deleted),
- url(r'^invoiceadmin/refunded/$', postgresqleu.invoices.views.refunded),
url(r'^invoiceadmin/search/$', postgresqleu.invoices.views.search),
url(r'^invoiceadmin/(\d+)/$', postgresqleu.invoices.views.oneinvoice),
url(r'^invoiceadmin/(new)/$', postgresqleu.invoices.views.oneinvoice),
@@ -241,11 +241,11 @@ urlpatterns.extend([
url(r'^invoices/(\d+)/$', postgresqleu.invoices.views.viewinvoice),
url(r'^invoices/(\d+)/pdf/$', postgresqleu.invoices.views.viewinvoicepdf),
url(r'^invoices/(\d+)/receipt/$', postgresqleu.invoices.views.viewreceipt),
- url(r'^invoices/(\d+)/refundnote/$', postgresqleu.invoices.views.viewrefundnote),
+ url(r'^invoices/(\d+)/refundnote/(\d+)/$', postgresqleu.invoices.views.viewrefundnote),
url(r'^invoices/(\d+)/([a-z0-9]{64})/$', postgresqleu.invoices.views.viewinvoice_secret),
url(r'^invoices/(\d+)/([a-z0-9]{64})/pdf/$', postgresqleu.invoices.views.viewinvoicepdf_secret),
url(r'^invoices/(\d+)/([a-z0-9]{64})/receipt/$', postgresqleu.invoices.views.viewreceipt_secret),
- url(r'^invoices/(\d+)/([a-z0-9]{64})/refundnote/$', postgresqleu.invoices.views.viewrefundnote_secret),
+ url(r'^invoices/(\d+)/([a-z0-9]{64})/refundnote/(\d+)/$', postgresqleu.invoices.views.viewrefundnote_secret),
url(r'^invoices/dummy/(\d+)/([a-z0-9]{64})/$', postgresqleu.invoices.views.dummy_payment),
url(r'^invoices/$', postgresqleu.invoices.views.userhome),
url(r'^invoices/banktransfer/$', postgresqleu.invoices.views.banktransfer),
diff --git a/postgresqleu/util/misc/baseinvoice.py b/postgresqleu/util/misc/baseinvoice.py
index b60ae5a8..48a4ec44 100755
--- a/postgresqleu/util/misc/baseinvoice.py
+++ b/postgresqleu/util/misc/baseinvoice.py
@@ -316,7 +316,7 @@ class BaseInvoice(PDFBase):
class BaseRefund(PDFBase):
- def __init__(self, recipient, invoicedate, refunddate, invoicenum, invoiceamount, invoicevat, refundamount, refundvat, paymentmethod):
+ def __init__(self, recipient, invoicedate, refunddate, invoicenum, invoiceamount, invoicevat, refundamount, refundvat, paymentmethod, refundid, reason, previousamount, previousvat):
super(BaseRefund, self).__init__(recipient)
self.title = "Refund of invoice {0}".format(invoicenum)
self.recipient = recipient
@@ -328,13 +328,19 @@ class BaseRefund(PDFBase):
self.refundamount = refundamount
self.refundvat = refundvat
self.paymentmethod = paymentmethod
+ self.refundid = refundid
+ self.reason = reason
+ self.previousamount = previousamount
+ self.previousvat = previousvat
self.prepare()
def save(self):
self.draw_header()
- self.canvas.drawCentredString(cm(10.5), cm(19), "REFUND NOTE FOR INVOICE NUMBER {0}".format(self.invoicenum))
+ self.canvas.drawCentredString(cm(10.5), cm(19), "REFUND NOTE {0} FOR INVOICE NUMBER {1}".format(self.refundid, self.invoicenum))
+
+ self.canvas.drawString(cm(2), cm(18), "Reason for refund: {0}".format(self.reason))
tblpaid = [
["Amount paid"],
@@ -346,6 +352,11 @@ class BaseRefund(PDFBase):
["Item", "Amount"],
["Amount", "{0:.2f} {1}".format(self.refundamount, settings.CURRENCY_SYMBOL)],
]
+ tblprevious = [
+ ["Amount previously refunded"],
+ ["Item", "Amount"],
+ ["Amount", "{0:.2f} {1}".format(self.previousamount, settings.CURRENCY_SYMBOL)],
+ ]
if self.invoicevat:
tblpaid.extend([
["VAT", "{0:.2f} {1}".format(self.invoicevat, settings.CURRENCY_SYMBOL)],
@@ -355,6 +366,10 @@ class BaseRefund(PDFBase):
["VAT", "{0:.2f} {1}".format(self.refundvat, settings.CURRENCY_SYMBOL)],
["", "{0:.2f} {1}".format(self.refundamount + self.refundvat, settings.CURRENCY_SYMBOL)],
])
+ tblprevious .extend([
+ ["VAT", "{0:.2f} {1}".format(self.previousvat, settings.CURRENCY_SYMBOL)],
+ ["", "{0:.2f} {1}".format(self.previousamount + self.previousvat, settings.CURRENCY_SYMBOL)],
+ ])
style = [
('SPAN', (0, 0), (1, 0)),
@@ -372,17 +387,26 @@ class BaseRefund(PDFBase):
t = Table(tblpaid, [cm(10.5), cm(2.5), cm(1.5), cm(2.5)])
t.setStyle(TableStyle(style))
w, h = t.wrapOn(self.canvas, cm(10), cm(10))
- t.drawOn(self.canvas, (self.canvas._pagesize[0] - w) / 2, cm(18) - h)
+ t.drawOn(self.canvas, (self.canvas._pagesize[0] - w) / 2, cm(17) - h)
+
+ if self.previousamount:
+ t = Table(tblprevious, [cm(10.5), cm(2.5), cm(1.5), cm(2.5)])
+ t.setStyle(TableStyle(style))
+ w, h = t.wrapOn(self.canvas, cm(10), cm(10))
+ t.drawOn(self.canvas, (self.canvas._pagesize[0] - w) / 2, cm(17) - h * 2 - cm(1))
+ extraofs = h + cm(1)
+ else:
+ extraofs = 0
t = Table(tblrefunded, [cm(10.5), cm(2.5), cm(1.5), cm(2.5)])
t.setStyle(TableStyle(style))
w, h = t.wrapOn(self.canvas, cm(10), cm(10))
- t.drawOn(self.canvas, (self.canvas._pagesize[0] - w) / 2, cm(18) - h * 2 - cm(1))
+ t.drawOn(self.canvas, (self.canvas._pagesize[0] - w) / 2, cm(17) - h * 2 - cm(1) - extraofs)
- self.canvas.drawCentredString(cm(10.5), cm(17.3) - h * 2 - cm(2), "This refund was issued {0}".format(self.refunddate.strftime("%B %d, %Y")))
+ self.canvas.drawCentredString(cm(10.5), cm(16.3) - h * 2 - cm(2) - extraofs, "This refund was issued {0}".format(self.refunddate.strftime("%B %d, %Y")))
if self.paymentmethod:
- self.canvas.drawCentredString(cm(10.5), cm(17.3) - h * 2 - cm(3), "Refunded to the original form of payment: {0}.".format(self.paymentmethod))
+ self.canvas.drawCentredString(cm(10.5), cm(16.3) - h * 2 - cm(3) - extraofs, "Refunded to the original form of payment: {0}.".format(self.paymentmethod))
self.canvas.showPage()
self.canvas.save()
diff --git a/template/confreg/admin_dashboard_single.html b/template/confreg/admin_dashboard_single.html
index 554bd31f..696da2af 100644
--- a/template/confreg/admin_dashboard_single.html
+++ b/template/confreg/admin_dashboard_single.html
@@ -99,6 +99,9 @@
<div class="col-md-3 col-sm-6 col-xs-12 buttonrow"><a class="btn btn-default btn-block" href="/events/admin/{{c.urlname}}/regdays/">Registration days</a></div>
<div class="col-md-3 col-sm-6 col-xs-12 buttonrow"><a class="btn btn-default btn-block" href="/events/admin/{{c.urlname}}/regclasses/">Registration classes</a></div>
<div class="col-md-3 col-sm-6 col-xs-12 buttonrow"><a class="btn btn-default btn-block" href="/events/admin/{{c.urlname}}/regtypes/">Registration types</a></div>
+ <div class="col-md-3 col-sm-6 col-xs-12 buttonrow"><a class="btn btn-default btn-block" href="/events/admin/{{c.urlname}}/refundpatterns/">Refund patterns</a></div>
+</div>
+<div class="row">
<div class="col-md-3 col-sm-6 col-xs-12 buttonrow"><a class="btn btn-default btn-block" href="/events/admin/{{c.urlname}}/addopts/">Additional options</a></div>
</div>
<div class="row">
diff --git a/template/confreg/admin_registration_cancel.html b/template/confreg/admin_registration_cancel.html
index c8447c00..a363a9fa 100644
--- a/template/confreg/admin_registration_cancel.html
+++ b/template/confreg/admin_registration_cancel.html
@@ -1,50 +1,31 @@
{%extends "confreg/confadmin_base.html" %}
{%load date_or_string%}
-{%block extrahead%}
-<script language="javascript">
-function confirmit() {
- return confirm('Are you absolutely sure you want to cancel this registration? There is no way to roll it back!');
-}
-</script>
-{%endblock%}
-
{%block title%}Cancel registration{%endblock%}
{%block layoutblock%}
<h1>Cancel registration</h1>
-<h2>{{reg.fullname}}</h2>
-{%if reg.invoice%}
-<p>
- This registration has an invoice attached to it. If you want to do a refund of this
- invoice (full or partial), the cancellation must currently be done through the
- invoice system.
-</p>
-<a class="btn btn-default btn-block" href="/invoiceadmin/{{reg.invoice.pk}}/refund/">Cancel with refund</a>
-{%elif reg.bulkpayment%}
-<p>
- This registration is part of a bulk payment or a pay by somebody else invoice.
- If you want to do a refund of this registration, has to be done through the
- invoice system. However, in doing so the actual registration will not be canceled,
- so you will <i>also</i> need to manually cancel thre reservation in question.
-</p>
-<a class="btn btn-default btn-block" href="/invoiceadmin/{{reg.bulkpayment.invoice.pk}}/refund/">Refund bulk invoice</a>
-{%elif reg.payconfirmedat%}
-<p>
- This registration does not have an invoice or bulk payment. That means it was either
- a no-pay registration (such as voucher) or a manually confirmed one (speaker, staff,
- or fully manual).
-</p>
-{%else%}
-<p>
- This registration has not been finalized, and can be removed without refund.
-</p>
-{%endif%}
-<form method="post" action=".">{% csrf_token %}
- <input type="hidden" name="docancel" value="1">
- <input type="submit" class="btn btn-default btn-block" value="{%if payconfirmedat%}Cancel registration without refund{%else%}Remove unconfirmed registration{%endif%}" onclick="return confirmit()">
-</form>
-<p></p>
-<a class="btn btn-default btn-block" href="/events/admin/{{conference.urlname}}/regdashboard/list/{{reg.id}}/">Back to registration</a>
+<table class="table">
+ <tr>
+ <th>Name</th>
+ <td>{{reg.fullname}}</td>
+ </tr>
+ <tr>
+ <th>Payment information</th>
+ <td>{{reg.payment_method_description|linebreaksbr}}</td>
+ </tr>
+ <tr>
+ <th>Total paid for attendee</th>
+ <td>{{currency_symbol}}{{totalnovat}}{%if totalvat%} + VAT {{currency_symbol}}{{totalvat}} = {{currency_symbol}}{{totalwithvat}}{%endif%}</td>
+ </tr>
+</table>
+
+<h3>Cancel</h3>
+
+<div class="row">
+ <form method="post" action="." class="form-horizontal">{% csrf_token %}
+{%include "confreg/admin_backend_form_content.html" with savebutton="Cancel registration" cancelurl="../" cancelname="Return without canceling" %}
+ </form>
+</div>
{%endblock%}
diff --git a/template/confreg/admin_registration_cancel_confirm.html b/template/confreg/admin_registration_cancel_confirm.html
deleted file mode 100644
index 2613ca32..00000000
--- a/template/confreg/admin_registration_cancel_confirm.html
+++ /dev/null
@@ -1,21 +0,0 @@
-{%extends "confreg/confadmin_base.html" %}
-{%load date_or_string%}
-{%block title%}Cancel registration{%endblock%}
-
-{%block layoutblock%}
-<h1>Cancel registration</h1>
-<p>
- Registration for {{name}} has been cancelled.
-</p>
-<p>
-{%if conference.sendwelcomemail%}
-An email has been sent to the attendee confirming the cancelation.
-{%else%}
-Since welcome email is not enabled for this conference, no
-email was sent to the attendee about the cancelation.
-{%endif%}
-</p>
-
-<a class="btn btn-default btn-block" href="/events/admin/{{conference.urlname}}/regdashboard/list/">Back to registration list</a>
-
-{%endblock%}
diff --git a/template/confreg/admin_registration_single.html b/template/confreg/admin_registration_single.html
index 2af77fe3..7a9f10b0 100644
--- a/template/confreg/admin_registration_single.html
+++ b/template/confreg/admin_registration_single.html
@@ -176,9 +176,7 @@
</table>
<a class="btn btn-default btn-block" href="/events/admin/{{conference.urlname}}/regdashboard/list/{{reg.id}}/edit/">Edit registration</a>
-{%if reg.payconfirmedat%}
<a class="btn btn-default btn-block" href="/events/admin/{{conference.urlname}}/regdashboard/list/{{reg.id}}/cancel/">Cancel registration</a>
-{%endif%}
{%if reg.can_edit %}
<a class="btn btn-default btn-block" href="/events/admin/{{conference.urlname}}/regdashboard/list/{{reg.id}}/cancel/">Remove unconfirmed registration entry</a>
{%endif%}
diff --git a/template/confsponsor/admin_sponsor_details.html b/template/confsponsor/admin_sponsor_details.html
index ca765b75..1c2b236b 100644
--- a/template/confsponsor/admin_sponsor_details.html
+++ b/template/confsponsor/admin_sponsor_details.html
@@ -39,7 +39,9 @@
{%if sponsor.invoice%}
<tr>
<th>Payment status:</th>
- <td>{%if sponsor.invoice.paidat%}Paid {{sponsor.invoice.paidat}}{%elif sponsor.invoice.isexpired%}Overdue{%else%}Invoiced{%endif%}</td>
+ <td>{%if sponsor.invoice.paidat%}Paid {{sponsor.invoice.paidat}}{%elif sponsor.invoice.isexpired%}Overdue{%else%}Invoiced{%endif%}
+ <a href="/invoiceadmin/{{sponsor.invoice.id}}/" class="btn btn-sm btn-default">View invoice</a>
+</td>
</tr>
{%endif%}
<tr>
diff --git a/template/invoices/home.html b/template/invoices/home.html
index 77306f67..4d3bfebd 100644
--- a/template/invoices/home.html
+++ b/template/invoices/home.html
@@ -16,8 +16,7 @@ tr.invoice-deleted {
<a type="button" class="btn btn-{%if unpaid%}primary{%elif has_unpaid%}warning{%else%}default{%endif%}" href="/invoiceadmin/">Unpaid</a>
<a type="button" class="btn btn-{%if pending%}primary{%elif has_pending%}warning{%else%}default{%endif%}" href="/invoiceadmin/pending/">Pending</a>
<a type="button" class="btn btn-{{paid|yesno:"primary,default"}}" href="/invoiceadmin/paid">Paid</a>
- <a type="button" class="btn btn-{{deleted|yesno:"primary,default"}}" href="/invoiceadmin/deleted/">Deleted</a>
- <a type="button" class="btn btn-{{refunded|yesno:"primary,default"}}" href="/invoiceadmin/refunded/">Refunded</a>
+ <a type="button" class="btn btn-{{deleted|yesno:"primary,default"}}" href="/invoiceadmin/deleted/">Canceled</a>
</div>
<div class="btn-group" role="group">
<form method="post" action="/invoiceadmin/search/">{% csrf_token %}
@@ -44,14 +43,14 @@ tr.invoice-deleted {
<th>Finalized</th>
</tr>
{%for invoice in invoices.object_list %}
-<tr class="{%if invoice.deleted%}invoice-deleted {%endif%}{%if invoice.isexpired and not invoice.deleted and not invoice.refund %}danger {%endif%}">
+<tr class="{%if invoice.deleted%}invoice-deleted {%endif%}{%if invoice.isexpired and not invoice.deleted %}danger {%endif%}">
<td><a href="/invoiceadmin/{{invoice.id}}/">{{invoice.id}}</a></td>
<td><a href="/invoiceadmin/{{invoice.id}}/">{{invoice.title}}</a></td>
<td style="white-space: nowrap">{{invoice.recipient_name}}</td>
<td style="white-space: nowrap; text-align: right;">{%if invoice.total_amount > 0%}{{invoice.total_amount|intcomma}}{%endif%}</td>
<td style="white-space: nowrap">{{invoice.invoicedate|date:"Y-m-d"}}</td>
<td style="white-space: nowrap" class="duedate" >{{invoice.duedate|date:"Y-m-d"}}</td>
- <td style="white-space: nowrap">{%if invoice.refund%}Refunded{%elif invoice.ispaid%}{{invoice.paidat|date:"Y-m-d"}}{%else%}No{%endif%}</td>
+ <td style="white-space: nowrap">{%if invoice.ispaid%}{{invoice.paidat|date:"Y-m-d"}}{%else%}No{%endif%}</td>
<td>{{invoice.finalized|yesno}}</td>
</tr>
{%endfor%}
diff --git a/template/invoices/invoiceform.html b/template/invoices/invoiceform.html
index 6942a3f6..ca1a7c6d 100644
--- a/template/invoices/invoiceform.html
+++ b/template/invoices/invoiceform.html
@@ -136,6 +136,11 @@ var vatmap = {
<div class="row buttonrow">
<a href="/invoices/{{invoice.id}}/" class="btn btn-default btn-block">View as recipient</a>
</div>
+{%if adminurl%}
+<div class="row buttonrow">
+ <a href="{{adminurl}}" class="btn btn-default btn-block">View related object in source system</a>
+</div>
+{%endif%}
{%endif%}
<div class="row">
@@ -144,21 +149,7 @@ var vatmap = {
<div class="alert alert-info">
This invoice has been <b>canceled.</b> The reason given was: <i>{{invoice.deletion_reason}}</i>.
</div>
-{%elif invoice.refund%}
-<h2>Refunded</h2>
-<div class="alert alert-info">
- <p>
- This invoice has been <b>refunded</b>!
- The reason given was: <i>{{invoice.refund.reason}}</i>.
- </p>
- <p>
- {%if invoice.refund.completed%}The refund process completed at {{invoice.refund.completed}}.
- {%elif invoice.refund.issued%}The refund was sent to the provider at {{invoice.refund.issued}}, and we are currently awaiting the results from there.
- {%else%}The refund was scheduled at {{invoice.refund.registered}} but has not yet been processed.
- {%endif%}
- </p>
-</div>
-{%else%} {%comment%}deleted/refunded{%endcomment%}
+{%else%} {%comment%}deleted{%endcomment%}
{%if invoice.ispaid%}
<h2>Paid</h2>
@@ -172,11 +163,63 @@ This invoice has been <b>paid</b>.
{%else%}
<p>This invoice was manually flagged as paid.</p>
{%endif%}
+<h2>Refunds</h2>
+{%if invoice.invoicerefund_set.all %}
+<table class="table table-bordered table-striped table-hover table-condensed">
+ <tr>
+ <th>Refund id</th>
+ <th>Amount</th>
+ <th>VAT</th>
+ <th>Description</th>
+ <th>Registered</th>
+ <th>Issued</th>
+ <th>Completed</th>
+ </tr>
+{%for refund in invoice.invoicerefund_set.all %}
+ <tr>
+ <td><a href="/invoices/{{invoice.id}}/refundnote/{{refund.id}}/">{{refund.id}}</a></td>
+ <td class="text-right">{{refund.amount}}</td>
+ <td class="text-right">{%if refund.vatamount%}{{refund.vatamount}} ({{refund.vatrate.shortname}}){%endif%}</td>
+ <td>{{refund.reason}}</td>
+ <td>{{refund.registered}}</td>
+ <td>{{refund.issued}}</td>
+ <td>{{refund.completed}}</td>
+ </tr>
+{%endfor%}
+{%if invoice.total_refunds.remaining.amount%}
+ <tr>
+ <th>Remaining</th>
+ <th class="text-right">{{invoice.total_refunds.remaining.amount}}</th>
+ <th class="text-right">{{invoice.total_refunds.remaining.vatamount}}</th>
+ <th></th>
+ <th></th>
+ <th></th>
+ </tr>
+{%endif%}
+</table>
+{%endif%}
+{%if invoice.processor%}
+<div class="row">
+ <div class="col-md-12">
+ This invoice was created using an automated system, and must also be refunded from
+ that system.
+ </div>
+</div>
+{%else%}
+{%if invoice.total_refunds.remaining.amount%}
<div class="row">
<div class="col-md-2">
<a href="refund/" class="btn btn-default btn-block">Refund invoice</a>
</div>
</div>
+{%else%}
+<div class="row">
+ <div class="col-md-12">
+ This invoice has been fully refunded.
+ </div>
+</div>
+{%endif%}{# remaining to refund#}
+{%endif%}{# automated or manual#}
{%else%}{# paid#}
{%if invoice.finalized%}
<h2>Payment</h2>
@@ -198,8 +241,22 @@ also be tagged with your userid <i>{{user.username}}</i>.
</div>
</form>
-{%endif%}
-{%endif%}
+<h2>Cancel</h2>
+<p>
+ As this invoice has not yet been paid, it can be canceled. This will generate a note to the
+ receiver that the invoice has been canceled, including the specified reason.
+</p>
+<form method="post" action="cancel/" class="inline-form">{% csrf_token %}
+ <div class="input-group col-md-6">
+ <input type="text" class="form-control" name="reason" placeholder="Reason for cancellation" />
+ <span class="input-group-btn">
+ <input type="submit" name="submit" value="Cancel invoice" onclick="return confirm_delete()" class="btn btn-default" />
+ </span>
+ </div>
+</form>
+
+{%endif%}{# finalized #}
+{%endif%}{# not paid #}
{%endif%}{%comment%}deleted/refunded{%endcomment%}
</div>
@@ -250,10 +307,10 @@ also be tagged with your userid <i>{{user.username}}</i>.
</form>
</div><!-- .row -->
-{%if invoice.finalized and not invoice.refund%}
+{%if invoice.finalized %}
<h2>Operations</h2>
<div class="row buttonrow">
-{%if not invoice.deleted and not invoice.ispaid and not invoice.refund %}
+{%if not invoice.deleted and not invoice.ispaid %}
<div class="col-md-2">
<div class="dropdown">
<button type="button" class="btn btn-default btn-block dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Send mail <span class="caret"></span></button>
@@ -281,17 +338,6 @@ also be tagged with your userid <i>{{user.username}}</i>.
</div>
{%endif%} {# autocancel #}
- <div class="col-md-4">
- <form method="post" action="cancel/" class="inline-form">{% csrf_token %}
- <div class="input-group" data-toggle="tooltip" data-placement="bottom" title="Note! The reason given is included in the cancellation notice to the end user!">
- <input type="text" class="form-control" name="reason" placeholder="Reason for cancellation" />
- <span class="input-group-btn">
- <input type="submit" name="submit" value="Cancel invoice" onclick="return confirm_delete()" class="btn btn-default" />
- </span>
- </div>
- </form>
- </div>
-
{%endif%} {# is not paid #}
</div>{# buttonrow #}
{%endif%}{# finalized #}
diff --git a/template/invoices/mail/invoice_refund.txt b/template/invoices/mail/invoice_refund.txt
index 4726960d..b76a1d1e 100644
--- a/template/invoices/mail/invoice_refund.txt
+++ b/template/invoices/mail/invoice_refund.txt
@@ -6,7 +6,7 @@ Invoice #{{invoice.id}}: {{invoice.title}}
has been refunded.
-{{currency_abbrev}} {{invoice.refund.fullamount}} of the total {{currency_abbrev}} {{invoice.total_amount}} has been returned to the
+{{currency_abbrev}} {{refund.fullamount}} of the total {{currency_abbrev}} {{invoice.total_amount}} has been returned to the
original payment source. Depending on which payment method
was used, it may take a few days before the funds arrive
in the account.
diff --git a/template/invoices/mail/invoice_refund_initiated.txt b/template/invoices/mail/invoice_refund_initiated.txt
index 4d566962..20e4ea41 100644
--- a/template/invoices/mail/invoice_refund_initiated.txt
+++ b/template/invoices/mail/invoice_refund_initiated.txt
@@ -1,6 +1,6 @@
Hello!
-A refund of {{currency_abbrev}} {{invoice.refund.fullamount}} of the invoice:
+A refund of {{currency_abbrev}} {{refund.fullamount}} of the invoice:
Invoice #{{invoice.id}}: {{invoice.title}}
diff --git a/template/invoices/refundform.html b/template/invoices/refundform.html
index 638c04a9..b92c9ea9 100644
--- a/template/invoices/refundform.html
+++ b/template/invoices/refundform.html
@@ -1,178 +1,93 @@
{%extends "adm/admin_base.html" %}
{%block title%}Invoice Refund{%endblock%}
-{%block extrahead%}
-<script type="text/javascript" src="/media/jq/jquery-1.8.2.min.js"></script>
-<style>
-input[readonly="readonly"], textarea[readonly="readonly"] {
- background-color: lightgray;
-}
-ul.messages {
- background-color: yellow;
- list-style-type: none;
- padding: 2px;
- padding-left: 20px;
-
-}
-table.refundtable tr td:nth-child(1) {
- width: 130px;
-}
-table.refundtable tr td:nth-child(2) {
- text-align: right;
-}
-table.refundtable tr td input {
- width: 250px;
-}
-table.refundtable tr td input[type="number"] {
- width: 100px;
-}
-table.refundtable table.invoicerows {
- width: 600px;
-}
-table.refundtable table.invoicerows th:nth-child(1) {
- text-align: center;
-}
-table.refundtable table.invoicerows td:nth-child(1) {
- width: 350px;
- text-align: left;
-}
-table.refundtable input[type="number"] {
- text-align: right;
-}
-</style>
-<script language="javascript">
-$(function() {
- $('#refundpercent').change(function() {
- var p = $('#refundpercent').val();
- $('#id_amount').val({{invoice.amount_without_vat}}*p/100);
- $('#id_vatamount').val({{invoice.total_vat}}*p/100);
- $('#totalrefund').text(Number($('#id_amount').val()) + Number($('#id_vatamount').val()));
- $('#id_vatrate').val({{globalvat}});
- });
-
- $('#id_amount, #id_vatamount').change(function() {
- $('#totalrefund').text(Number($('#id_amount').val()) + Number($('#id_vatamount').val()));
- });
-
-{%if samevat%}
- $('#refundpercent').focus();
-{%else%}
- $('#id_amount').focus();
-{%endif%}
-});
-
-</script>
-{%endblock%}
{%block layoutblock %}
<h1>Refund of invoice #{{invoice.id}}</h1>
-<a href="../" class="btn btn-default">Back to invoice</a>
-
-{% if messages %}
- <ul class="messages">
- {% for message in messages %}
- <li{% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}</li>
- {% endfor %}
- </ul>
-{% endif %}
-
-<form method="post" action=".">{% csrf_token %}
-<table class="refundtable">
+<table class="table table-bordered table-striped table-hover table-condensed">
<tr>
- <td colspan="2"><b>#{{invoice.id}} - {{invoice.title}}</b></td>
+ <th>Invoice number:</th>
+ <td class="text-right">{{invoice.id}}</td>
</tr>
<tr>
- <td>Invoice number:</td>
- <td>{{invoice.id}}</td>
- </tr>
- <tr>
- <td>Total without VAT:</td>
- <td>{{currency_symbol}}{{invoice.amount_without_vat}}</td>
- </tr>
- <tr>
- <td>Total VAT:</td>
- <td>{{currency_symbol}}{{invoice.total_vat}}</td>
- </tr>
- <tr>
- <td>Total amount:</td>
- <td>{{currency_symbol}}{{invoice.total_amount}}</td>
+ <th>Payment fees:</th>
+ <td class="text-right">{{currency_symbol}}{{invoice.payment_fees}}</td>
</tr>
+</table>
+
+<h3>Invoice rows</h3>
+<table class="table table-bordered table-striped table-hover table-condensed">
+<tr>
+ <th>Text</th>
+ <th>Amount / item ex VAT</th>
+ <th>Count</th>
+ <th>Total ex VAT</th>
+ <th>Total VAT</th>
+ <th>Total with VAT</th>
+ <th>VAT rate</th>
+</tr>
+{%for r in invoice.invoicerow_set.all%}
<tr>
- <td>Payment fees:</td>
- <td>{{currency_symbol}}{{invoice.payment_fees}}</td>
+ <td>{{r.rowtext}}</td>
+ <td class="text-right">{{r.rowamount}}</td>
+ <td class="text-right">{{r.rowcount}}</td>
+ <td class="text-right">{{r.totalrow}}</td>
+ <td class="text-right">{{r.totalvat}}</td>
+ <td class="text-right">{{r.totalwithvat}}</td>
+ <td>{{r.vatrate}}</td>
</tr>
+{%endfor%}
+<tr>
+ <th>Total</th>
+ <th></th>
+ <th></th>
+ <th class="text-right">{{invoice.amount_without_vat}}</th>
+ <th class="text-right">{{invoice.total_vat}}</th>
+ <th class="text-right">{{invoice.total_amount}}</th>
+ <th></th>
+</tr>
+</table>
+
+{%if invoice.invoicerefund_set.all %}
+<h3>Previous refunds</h3>
+<table class="table table-bordered table-striped table-hover table-condensed">
<tr>
- <td>Amount remaining:</td>
- <td>{{currency_symbol}}{{invoice.amount_without_fees}}</td>
+ <th>Refund id</th>
+ <th>Amount</th>
+ <th>VAT</th>
+ <th>Description</th>
+ <th>Registered</th>
+ <th>Issued</th>
+ <th>Completed</th>
</tr>
+{%for refund in invoice.invoicerefund_set.all %}
<tr>
- <td>VAT rates used</td>
- <td>{{invoice.used_vatrates}}</td>
+ <td><a href="/invoices/{{invoice.id}}/refundnote/{{refund.id}}/">{{refund.id}}</a></td>
+ <td class="text-right">{{refund.amount}}</td>
+ <td class="text-right">{%if refund.vatamount%}{{refund.vatamount}} ({{refund.vatrate.shortname}}){%endif%}</td>
+ <td>{{refund.reason}}</td>
+ <td>{{refund.registered}}</td>
+ <td>{{refund.issued}}</td>
+ <td>{{refund.completed}}</td>
</tr>
- <tr>
- <td>Invoice rows:</td>
- <td>
- <table class="invoicerows">
- <tr>
- <th>Text</th>
- <th>Amount ex VAT</th>
- <th>VAT</th>
- <th>Amount inc VAT</th>
- </tr>
-{%for r in invoice.invoicerow_set.all%}
- <tr>
- <td>{{r.rowtext}}</td>
- <td>{{r.totalrow}}</td>
- <td>{{r.totalvat}}</td>
- <td>{{r.totalwithvat}}</td>
- </tr>
{%endfor%}
- </table>
- </td>
- </tr>
<tr>
- <td colspan="2"><hr/></td>
+ <th>Total refunded</th>
+ <th class="text-right">{{invoice.total_refunds.amount}}</th>
+ <th class="text-right">{{invoice.total_refunds.vatamount}}</th>
+ <th colspan="4"></th>
</tr>
-{%if samevat%}
<tr>
- <td>Calculate refund percentage</td>
- <td><input type="number" min="0" max="100" id="refundpercent" name="refundpercent" value="0"></td>
+ <td><i>Remaining to refund</i></td>
+ <td class="text-right">{{invoice.total_refunds.remaining.amount}}</td>
+ <td class="text-right">{{invoice.total_refunds.remaining.vatamount}}</td>
+ <td colspan="4">Not reduced with payment fees of {{currency_symbol}}{{invoice.payment_fees}}</td>
</tr>
- <tr>
- <td colspan="2"><hr/></td>
- </tr>
-{%endif%}
-
-{%if form.errors%}
- <tr class="errorheader">
- <td colspan="2">Please correct the errors below, and re-submit the form.</td>
- </tr>
-{%endif%}
-{%for field in form%}
- {%if field.errors %}
- <tr class="error">
- <td colspan="2">{{field.errors.as_ul}}</td>
- </tr>
- {%endif%}
- <tr {%if field.errors%}class="errorinfo"{%endif%}>
- <th>{{field.label_tag}}</th>
- <td>{{field}} {{field.help_text|safe}}</td>
- </tr>
-{%endfor%}
- <tr>
- <td>Total amount to refund</td>
- <td><span id="totalrefund">0</span></td>
- </tr>
</table>
-{%if not invoice.can_autorefund%}
- <p>
- <b>NOTE!</b> This invoice was paid using {{invoice.paidusing|default:"an unknown method"}}, which does not support automatic refunds!
- </p>
- <p>
- You must issue the refund <b>manually</b>,and then flag it here.
- </p>
{%endif%}
-<input type="submit" name="submit" value="{{invoice.can_autorefund|yesno:"Issue refund,Flag as refunded"}}" class="btn btn-primary" />
-<br/><br/>
-<a href="../" class="btn btn-default">Back to invoice</a>
+<h2>Refund invoice</h2>
+<form method="post" action="." class="form-horizontal">{% csrf_token %}
+{%include "confreg/admin_backend_form_content.html" with savebutton=invoice.can_autorefund|yesno:"Issue refund,Flag as refunded" cancelurl="../" %}
+</form>
+
{%endblock%}
diff --git a/template/invoices/refundform_complete.html b/template/invoices/refundform_complete.html
deleted file mode 100644
index c929354b..00000000
--- a/template/invoices/refundform_complete.html
+++ /dev/null
@@ -1,48 +0,0 @@
-{%extends "adm/admin_base.html" %}
-{%block title%}Invoice Refund{%endblock%}
-{%block extrahead%}
-<style>
-input[readonly="readonly"], textarea[readonly="readonly"] {
- background-color: lightgray;
-}
-ul.messages {
- background-color: yellow;
- list-style-type: none;
- padding: 2px;
- padding-left: 20px;
-}
-</style>
-{%endblock%}
-{%block layoutblock %}
-<h1>Refund of invoice #{{invoice.id}}</h1>
-{%if invoice.can_autorefund%}
-<p>
- The refund has been scheduled, and will be processed by a background
- job shortly. You can keep track of the status on the main invoice page.
-</p>
-<p>
- Accounting entries will be added automatically once the process has
- completed.
-</p>
-
-{%else%}
-<p>
- The invoice has been flagged as refunded.
-</p>
-{%if invoice.accounting_account%}
-<p>
- An entry has been added to the accounting system but left open, as the
- source account for the refund cannot be automatically determined. Go into
- the accounting system and fix that.
-</p>
-{%else%}
-<p>
- As there was no account set on this invoice, no entry has been generated
- in the accounting system. That has to be handled manually.
-</p>
-{%endif%}
-
-{%endif%}
-
-<p><a href="../">Back</a> to invoice.</p>
-{%endblock%}
diff --git a/template/invoices/userhome.html b/template/invoices/userhome.html
index f69ffeb1..5a2e77f1 100644
--- a/template/invoices/userhome.html
+++ b/template/invoices/userhome.html
@@ -19,10 +19,8 @@ We have the following invoices registered to your account:
<tr>
<td>#{{invoice.id}}</td>
<td><a href="{{invoice.id}}/">{{invoice.title}}</td>
- <td>{%if invoice.refund%}
- {%if invoice.refund.completed%}<span class="badge badge-info">Refunded</span>
- {%else%}<span class="badge badge-warning">Refund pending</span>
- {%endif%}
+ <td>{%if invoice.invoicerefund_set.exists%}
+ <span class="badge badge-info">Refunded</span>
{%elif invoice.ispaid%}<span class="badge badge-success">Paid</span>
{%else%}<span class="badge badge-primary">Awaiting payment</span>{%endif%}
</td>
diff --git a/template/invoices/userinvoice_spec.html b/template/invoices/userinvoice_spec.html
index e7fec38f..6163be52 100644
--- a/template/invoices/userinvoice_spec.html
+++ b/template/invoices/userinvoice_spec.html
@@ -7,11 +7,7 @@
<td style="white-space: nowrap">Invoice status:</td>
<td>
{%if invoice.ispaid %}
- {%if invoice.refund %}
- {%if invoice.refund.completed%}<b>Refunded</b>{%elif invoice.refund.issued%}<b>Refund submitted</b> to payment provider and will complete soon.{%else%}<b>Pending refund</b>{%endif%}
- {%else%}
<b>Paid</b>
- {%endif%}
{%else%}
<b>Awaiting payment</b>
{%endif%}
@@ -75,10 +71,25 @@
</td>
</tr>
{%endif%}
-{%if invoice.refund and invoice.refund.refund_pdf%}
+{%if invoice.invoicerefund_set.all %}
<tr>
- <td style="white-space: nowrap">Refund:</td>
- <td><a href="/invoices/{{invoice.pk}}/{%if fromsecret or not invoice.has_recipient_user%}{{invoice.recipient_secret}}/{%endif%}refundnote/">View refund note</a></td>
+ <td style="white-space: nowrap">Refunds:</td>
+ <td>
+ <table class="table table-sm table-condensed">
+ <tr>
+ <th>Refund amount</th>
+ <th>Refund status</th>
+ <th>Refund note</th>
+ </tr>
+{%for r in invoice.invoicerefund_set.all %}
+ <tr>
+ <td>{{currency_symbol}}{{r.fullamount}}</td>
+ <td>{%if r.completed%}Refunded {{r.completed|date:"Y-m-d"}}{%elif r.issued%}Submitted to payment provider {{r.issued|date:"Y-m-d"}}{%else%}Pending since {{r.registered|date:"Y-m-d"}}{%endif%}</td>
+ <td><a href="/invoices/{{invoice.pk}}/{%if fromsecret or not invoice.has_recipient_user%}{{invoice.recipient_secret}}/{%endif%}refundnote/{{r.id}}/">View refund note</a></td>
+ </tr>
+{%endfor%}
+ </table>
+ </td>
</tr>
{%endif%}