diff options
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%} |
