Fix #571 -- Partial payments and refunds

This commit is contained in:
Raphael Michel
2018-06-26 12:09:36 +02:00
parent 8e7af49206
commit 18a378976b
115 changed files with 6026 additions and 1598 deletions

View File

@@ -7,8 +7,8 @@ from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from pretix.base.models import (
Checkin, Event, Invoice, Item, Order, OrderPosition, Organizer, Question,
QuestionAnswer, SubEvent,
Checkin, Event, Invoice, Item, Order, OrderPayment, OrderPosition,
OrderRefund, Organizer, Question, QuestionAnswer, SubEvent,
)
from pretix.base.signals import register_payment_providers
from pretix.control.forms.widgets import Select2
@@ -152,14 +152,21 @@ class OrderFilterForm(FilterForm):
qs = qs.filter(status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
elif s == 'ne':
qs = qs.filter(status__in=[Order.STATUS_PENDING, Order.STATUS_EXPIRED])
else:
elif s in ('p', 'n', 'e', 'c', 'r'):
qs = qs.filter(status=s)
if fdata.get('ordering'):
qs = qs.order_by(self.get_order_by())
if fdata.get('provider'):
qs = qs.filter(payment_provider=fdata.get('provider'))
qs = qs.annotate(
has_payment_with_provider=Exists(
OrderPayment.objects.filter(
Q(order=OuterRef('pk')) & Q(provider=fdata.get('provider'))
)
)
)
qs = qs.filter(has_payment_with_provider=1)
return qs
@@ -187,6 +194,23 @@ class EventOrderFilterForm(OrderFilterForm):
answer = forms.CharField(
required=False
)
status = forms.ChoiceField(
label=_('Order status'),
choices=(
('', _('All orders')),
('p', _('Paid')),
('n', _('Pending')),
('o', _('Pending (overdue)')),
('np', _('Pending or paid')),
('e', _('Expired')),
('ne', _('Pending or expired')),
('c', _('Canceled')),
('r', _('Refunded')),
('overpaid', _('Overpaid')),
('underpaid', _('Underpaid')),
),
required=False,
)
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
@@ -247,6 +271,18 @@ class EventOrderFilterForm(OrderFilterForm):
)
qs = qs.annotate(has_answer=Exists(answers)).filter(has_answer=True)
if fdata.get('status') == 'overpaid':
qs = qs.filter(
Q(~Q(status__in=[Order.STATUS_REFUNDED, Order.STATUS_CANCELED]) & Q(pending_sum_t__lt=0))
| Q(Q(status__in=[Order.STATUS_REFUNDED, Order.STATUS_CANCELED]) & Q(pending_sum_rc__lt=0))
| Q(Q(status__in=[Order.STATUS_EXPIRED, Order.STATUS_PENDING]) & Q(pending_sum_rc__lte=0))
)
elif fdata.get('status') == 'underpaid':
qs = qs.filter(
status=Order.STATUS_PAID,
pending_sum_t__gt=0
)
return qs
@@ -793,3 +829,43 @@ class VoucherFilterForm(FilterForm):
qs = qs.order_by(self.get_order_by())
return qs
class RefundFilterForm(FilterForm):
provider = forms.ChoiceField(
label=_('Payment provider'),
choices=[
('', _('All payment providers')),
],
required=False,
)
status = forms.ChoiceField(
label=_('Refund status'),
choices=(
('', _('All open refunds')),
('all', _('All refunds')),
) + OrderRefund.REFUND_STATES,
required=False,
)
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
super().__init__(*args, **kwargs)
self.fields['provider'].choices += [(k, v.verbose_name) for k, v
in self.event.get_payment_providers().items()]
def filter_qs(self, qs):
fdata = self.cleaned_data
qs = super().filter_qs(qs)
if fdata.get('provider'):
qs = qs.filter(provider=fdata.get('provider'))
if fdata.get('status'):
if fdata.get('status') != 'all':
qs = qs.filter(state=fdata.get('status'))
else:
qs = qs.filter(state__in=[OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT,
OrderRefund.REFUND_STATE_EXTERNAL])
return qs

View File

@@ -344,3 +344,47 @@ class OrderMailForm(forms.Form):
validators=[PlaceholderValidator(['{expire_date}', '{event}', '{code}', '{date}', '{url}',
'{invoice_name}', '{invoice_company}'])]
)
class OrderRefundForm(forms.Form):
action = forms.ChoiceField(
required=False,
widget=forms.RadioSelect,
choices=(
('mark_refunded', _('Mark the complete order as refunded. The order will be canceled and all tickets will '
'no longer work. This can not be reverted.')),
('mark_pending', _('Mark the order as pending and allow the user to pay the open amount with another '
'payment method.')),
('do_nothing', _('Do nothing and keep the order as it is.')),
)
)
mode = forms.ChoiceField(
required=False,
widget=forms.RadioSelect,
choices=(
('full', 'Full refund'),
('partial', 'Partial refund'),
)
)
partial_amount = forms.DecimalField(
required=False, max_digits=10, decimal_places=2,
localize=True
)
def __init__(self, *args, **kwargs):
self.order = kwargs.pop('order')
super().__init__(*args, **kwargs)
change_decimal_field(self.fields['partial_amount'], self.order.event.currency)
def clean_partial_amount(self):
max_amount = self.order.total - self.order.pending_sum
val = self.cleaned_data.get('partial_amount')
if val is not None and (val > max_amount or val <= 0):
raise ValidationError(_('The refund amount needs to be positive and less than {}.').format(max_amount))
return val
def clean(self):
data = self.cleaned_data
if data.get('mode') == 'partial' and not data.get('partial_amount'):
raise ValidationError(_('You need to specify an amount for a partial refund.'))
return data

View File

@@ -173,7 +173,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.order.comment': _('The order\'s internal comment has been updated.'),
'pretix.event.order.checkin_attention': _('The order\'s flag to require attention at check-in has been '
'toggled.'),
'pretix.event.order.payment.changed': _('The payment method has been changed.'),
'pretix.event.order.payment.changed': _('A new payment {local_id} has been started instead of the previous one.'),
'pretix.event.order.email.sent': _('An unidentified type email has been sent.'),
'pretix.event.order.email.custom_sent': _('A custom email has been sent.'),
'pretix.event.order.email.download_reminder_sent': _('An email has been sent with a reminder that the ticket '
@@ -186,6 +186,13 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.order.email.order_paid': _('An email has been sent to notify the user that payment has been received.'),
'pretix.event.order.email.order_placed': _('An email has been sent to notify the user that the order has been received and requires payment.'),
'pretix.event.order.email.resend': _('An email with a link to the order detail page has been resent to the user.'),
'pretix.event.order.payment.confirmed': _('Payment {local_id} has been confirmed.'),
'pretix.event.order.payment.canceled': _('Payment {local_id} has been canceled.'),
'pretix.event.order.payment.started': _('Payment {local_id} has been started.'),
'pretix.event.order.refund.created': _('Refund {local_id} has been created.'),
'pretix.event.order.refund.created.externally': _('Refund {local_id} has been created by an external entity.'),
'pretix.event.order.refund.done': _('Refund {local_id} has been completed.'),
'pretix.event.order.refund.canceled': _('Refund {local_id} has been canceled.'),
'pretix.control.auth.user.created': _('The user has been created.'),
'pretix.user.settings.2fa.enabled': _('Two-factor authentication has been enabled.'),
'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'),

View File

@@ -89,6 +89,12 @@
{% trans "Overview" %}
</a>
</li>
<li>
<a href="{% url 'control:event.orders.refunds' organizer=request.event.organizer.slug event=request.event.slug %}"
{% if url_name == "event.orders.refunds" %}class="active"{% endif %}>
{% trans "Refunds" %}
</a>
</li>
<li>
<a href="{% url 'control:event.orders.export' organizer=request.event.organizer.slug event=request.event.slug %}"
{% if url_name == "event.orders.export" %}class="active"{% endif %}>

View File

@@ -15,6 +15,25 @@
{% endif %}
</small>
</h1>
{% if has_overpaid_orders %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
This event contains <strong>overpaid orders</strong>, for example due to duplicate payment attempts.
You should review the cases and consider refunding the overpaid amount to the user.
{% endblocktrans %}
<a href="{% url "control:event.orders" event=request.event.slug organizer=request.event.organizer.slug %}?status=overpaid"
class="btn btn-primary">{% trans "Show overpaid orders" %}</a>
</div>
{% endif %}
{% if has_pending_refunds %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
This event contains <strong>pending refunds</strong> that you should take care of.
{% endblocktrans %}
<a href="{% url "control:event.orders.refunds" event=request.event.slug organizer=request.event.organizer.slug %}"
class="btn btn-primary">{% trans "Show pending refunds" %}</a>
</div>
{% endif %}
{% if actions|length > 0 %}
<div class="panel panel-danger">
<div class="panel-heading">

View File

@@ -25,7 +25,8 @@
<div class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group">
{% if order.status == 'n' or order.status == 'e' %}
<a href="{% url "control:event.order.transition" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}?status=p" class="btn btn-default">
<a href="{% url "control:event.order.transition" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}?status=p"
class="btn {% if overpaid >= 0 %}btn-primary{% else %}btn-default{% endif %}">
{% trans "Mark as paid" %}
</a>
<a href="{% url "control:event.order.extend" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
@@ -36,10 +37,13 @@
<a href="{% url "control:event.order.transition" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}?status=c" class="btn btn-default">
{% trans "Cancel order" %}
</a>
{% elif order.status == 'p' %}
{% endif %}
{% if order.status == 'p' %}
<button name="status" value="n" class="btn btn-default">{% trans "Mark as not paid" %}</button>
<a href="{% url "control:event.order.transition" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}?status=r" class="btn btn-default">
{% trans "Refund order" %}
{% endif %}
{% if overpaid|add:order.total != 0 %}
<a href="{% url "control:event.order.refunds.start" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
{% trans "Create a refund" %}
</a>
{% endif %}
@@ -66,6 +70,26 @@
</form>
{% endif %}
{% if overpaid > 0 %}
<form action="{% url "control:event.order.refunds.start" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}"
method="post">
{% csrf_token %}
<input type="hidden" name="start-action" value="do_nothing">
<input type="hidden" name="start-mode" value="partial">
<input type="hidden" name="start-partial_amount" value="{{ overpaid }}">
<div class="alert alert-warning">
{% blocktrans trimmed with amount=overpaid|money:request.event.currency %}
This order is currently overpaid by {{ amount }}.
{% endblocktrans %}
<button class="btn btn-primary" type="submit">
{% blocktrans trimmed with amount=overpaid|money:request.event.currency %}
Initiate a refund of {{ amount }}
{% endblocktrans %}
</button>
</div>
</form>
{% endif %}
<div class="row">
<div class="col-xs-12 col-lg-10">
<div class="panel panel-primary items">
@@ -87,10 +111,7 @@
<span class="fa fa-edit"></span>
</a>
</dd>
{% if order.status == "p" %}
<dt>{% trans "Payment date" %}</dt>
<dd>{{ order.payment_date }}</dd>
{% elif order.status == "n" %}
{% if order.status == "n" %}
<dt>{% trans "Expiry date" %}</dt>
<dd>{{ order.expires }}</dd>
{% endif %}
@@ -207,7 +228,8 @@
</a>
{% endif %}
{% if line.subevent %}
<br /><span class="fa fa-calendar"></span> {{ line.subevent.name }} &middot; {{ line.subevent.get_date_range_display }}
<br/>
<span class="fa fa-calendar"></span> {{ line.subevent.name }} &middot; {{ line.subevent.get_date_range_display }}
{% endif %}
{% if line.has_questions %}
<dl>
@@ -239,7 +261,7 @@
{{ q.answer.file_name }}
</a>
<span class="label label-danger" data-toggle="tooltip"
title="{% trans "This file has been uploaded by a user and could contain viruses or other malicious content." %}">
title="{% trans "This file has been uploaded by a user and could contain viruses or other malicious content." %}">
{% trans "UNSAFE" %}
</span>
{% else %}
@@ -262,7 +284,7 @@
{% if event.settings.display_net_prices %}
<strong>{{ line.net_price|money:event.currency }}</strong>
{% if line.tax_rate %}
<br />
<br/>
<small>
{% blocktrans trimmed with rate=line.tax_rate taxname=line.tax_rule.name|default:s_taxes %}
<strong>plus</strong> {{ rate }}% {{ taxname }}
@@ -272,7 +294,7 @@
{% else %}
<strong>{{ line.price|money:event.currency }}</strong>
{% if line.tax_rate and line.price %}
<br />
<br/>
<small>
{% blocktrans trimmed with rate=line.tax_rate taxname=line.tax_rule.name|default:s_taxes %}
incl. {{ rate }}% {{ taxname }}
@@ -350,28 +372,154 @@
</div>
</div>
{% eventsignal event "pretix.control.signals.order_info" order=order request=request %}
<div class="row">
<div class="row payments">
<div class="{% if request.event.settings.invoice_address_asked or order.invoice_address %}col-md-6{% else %}col-md-12{% endif %}">
<div class="panel panel-default items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Payment information" %}
{% trans "Payments" %}
</h3>
</div>
<div class="panel-body">
{% if order.payment_manual %}
<div class="alert alert-info">
{% trans "The payment state of this order was manually modified." %}
</div>
{% endif %}
{{ payment }}
{% if order.status == 'n' %}
<p>{% blocktrans trimmed with date=order.expires %}
The payment has to be completed before {{ date }}.
{% endblocktrans %}</p>
{% endif %}
<div class="table-responsive">
<table class="table table-condensed">
<thead>
<tr>
<th>#</th>
<th>{% trans "Start date" %}</th>
<th>{% trans "Confirmation date" %}</th>
<th>{% trans "Payment method" %}</th>
<th>{% trans "Status" %}</th>
<th class="text-right">{% trans "Amount" %}</th>
</tr>
</thead>
<tbody>
{% for p in payments %}
<tr>
<td>{{ p.full_id }}</td>
<td>
{% if p.migrated %}
<span class="label label-default" data-toggle="tooltip"
title="{% trans "This payment was created with an older version of pretix, therefore accurate data might not be available." %}">
{% trans "MIGRATED" %}
</span>
{% else %}
{{ p.created|date:"SHORT_DATETIME_FORMAT" }}
{% endif %}
</td>
<td>{{ p.payment_date|date:"SHORT_DATETIME_FORMAT" }}</td>
<td>
{{ p.payment_provider.verbose_name }}
{% if p.state == "pending" or p.state == "created" %}
<a href="{% url "control:event.order.payments.cancel" event=request.event.slug organizer=request.event.organizer.slug code=order.code payment=p.pk %}"
class="btn btn-danger btn-xs" data-toggle="tooltip"
title="{% trans "Cancel payment" %}">
<span class="fa fa-times"></span>
</a>
<a href="{% url "control:event.order.payments.confirm" event=request.event.slug organizer=request.event.organizer.slug code=order.code payment=p.pk %}"
class="btn btn-primary btn-xs" data-toggle="tooltip"
title="{% trans "Confirm as paid" %}">
<span class="fa fa-check"></span>
</a>
{% endif %}
</td>
<td>
<span class="label label-{% if p.state == "created" or p.state == "pending" %}warning{% elif p.state == "confirmed" %}success{% else %}danger{% endif %}">
{{ p.get_state_display }}
</span>
</td>
<td class="text-right">{{ p.amount|money:request.event.currency }}</td>
</tr>
{% if p.html_info %}
<tr>
<td colspan="1"></td>
<td colspan="5">{{ p.html_info|safe }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% if refunds %}
<div class="panel panel-default items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Refunds" %}
</h3>
</div>
<div class="panel-body">
<div class="table-responsive">
<table class="table table-condensed">
<thead>
<tr>
<th>#</th>
<th>{% trans "Start date" %}</th>
<th>{% trans "Completion date" %}</th>
<th>{% trans "Source" %}</th>
<th>{% trans "Payment method" %}</th>
<th>{% trans "Payment" %}</th>
<th>{% trans "Status" %}</th>
<th class="text-right">{% trans "Amount" %}</th>
<th class="text-right"></th>
</tr>
</thead>
<tbody>
{% for r in refunds %}
<tr>
<td>{{ r.full_id }}</td>
<td>{{ r.created|date:"SHORT_DATETIME_FORMAT" }}</td>
<td>{{ r.execution_date|date:"SHORT_DATETIME_FORMAT" }}</td>
<td>{{ r.get_source_display }}</td>
<td>
{{ r.payment_provider.verbose_name }}
</td>
<td>
{% if r.payment %}
{{ r.payment.full_id }}
{% endif %}
</td>
<td>
<span class="label label-{% if r.state == "external" or r.state == "transit" or r.state == "created" %}warning{% elif r.state == "done" %}success{% else %}danger{% endif %}">
{{ r.get_state_display }}
</span>
</td>
<td class="text-right">{{ r.amount|money:request.event.currency }}</td>
<td class="text-right">
{% if r.state == "transit" or r.state == "created" %}
<a href="{% url "control:event.order.refunds.cancel" event=request.event.slug organizer=request.event.organizer.slug code=order.code refund=r.pk %}"
class="btn btn-danger btn-xs" data-toggle="tooltip"
title="{% trans "Cancel transfer" %}">
<span class="fa fa-times"></span>
</a>
<a href="{% url "control:event.order.refunds.done" event=request.event.slug organizer=request.event.organizer.slug code=order.code refund=r.pk %}"
class="btn btn-primary btn-xs" data-toggle="tooltip"
title="{% trans "Confirm as done" %}">
<span class="fa fa-check"></span>
</a>
{% elif r.state == "external" %}
<a href="{% url "control:event.order.refunds.cancel" event=request.event.slug organizer=request.event.organizer.slug code=r.order.code refund=r.pk %}?next={{ request.get_full_path|urlencode }}"
class="btn btn-default btn-xs" data-toggle="tooltip">
<span class="fa fa-times"></span>
{% trans "Ignore" %}
</a>
<a href="{% url "control:event.order.refunds.process" event=request.event.slug organizer=request.event.organizer.slug code=r.order.code refund=r.pk %}?next={{ request.get_full_path|urlencode }}"
class="btn btn-primary btn-xs"
data-toggle="tooltip">
<span class="fa fa-check"></span>
{% trans "Process refund" %}
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div>
{% if request.event.settings.invoice_address_asked or order.invoice_address %}
<div class="col-md-6">

View File

@@ -1,27 +1,18 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% block title %}
{% trans "Refund order" %}
{% trans "Cancel payment" %}
{% endblock %}
{% block content %}
<h1>
{% trans "Refund order" %}
<a class="btn btn-link btn-lg"
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
{% blocktrans trimmed with order=order.code %}
Back to order {{ order }}
{% endblocktrans %}
</a>
{% trans "Cancel payment" %}
</h1>
<p>{% blocktrans trimmed %}
Do you really want to cancel this payment? You cannot revert this action.
{% endblocktrans %}</p>
<form method="post" href="">
<p>{% blocktrans trimmed %}
Do you really want to refund this order? You cannot revert this action.
{% endblocktrans %}</p>
{{ payment|safe }}
{% csrf_token %}
<input type="hidden" name="status" value="r" />
<div class="row checkout-button-row">
<div class="col-md-4">
<a class="btn btn-block btn-default btn-lg"
@@ -31,7 +22,7 @@
</div>
<div class="col-md-4 col-md-offset-4">
<button class="btn btn-block btn-danger btn-lg" type="submit">
{% trans "Yes, refund order" %}
{% trans "Yes, cancel payment" %}
</button>
</div>
<div class="clearfix"></div>

View File

@@ -0,0 +1,36 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}
{% trans "Mark payment as complete" %}
{% endblock %}
{% block content %}
<h1>
{% trans "Mark payment as complete" %}
<a class="btn btn-link btn-lg"
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
{% blocktrans trimmed with order=order.code %}
Back to order {{ order }}
{% endblocktrans %}
</a>
</h1>
<form method="post" class="form-horizontal" href="">
{% csrf_token %}
<p>{% blocktrans trimmed %}
Do you really want to mark this payment as complete?
{% endblocktrans %}</p>
<input type="hidden" name="status" value="p" />
{% bootstrap_form form layout='horizontal' horizontal_label_class='sr-only' horizontal_field_class='col-md-12' %}
<div class="form-group submit-group">
<a class="btn btn-default btn-lg"
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
{% trans "Cancel" %}
</a>
<button class="btn btn-primary btn-save btn-lg" type="submit">
{% trans "Mark as paid" %}
</button>
<div class="clearfix"></div>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,38 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% block title %}
{% trans "Cancel refund" %}
{% endblock %}
{% block content %}
<h1>
{% trans "Cancel refund" %}
</h1>
<p>{% blocktrans trimmed %}
Do you really want to cancel this refund? You cannot revert this action.
{% endblocktrans %}</p>
<div class="alert alert-info">
{% blocktrans trimmed %}
If the money is already on the way back, this will not stop the money, it will just mark this transfer as
aborted in pretix. This will also not reactivate the order, it will just allow you to choose a new refund
method.
{% endblocktrans %}
</div>
<form method="post" href="">
{% csrf_token %}
<div class="row checkout-button-row">
<div class="col-md-4">
<a class="btn btn-block btn-default btn-lg"
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
{% trans "No, take me back" %}
</a>
</div>
<div class="col-md-4 col-md-offset-4">
<button class="btn btn-block btn-danger btn-lg" type="submit">
{% trans "Yes, cancel refund" %}
</button>
</div>
<div class="clearfix"></div>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,145 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load money %}
{% block title %}
{% trans "Refund order" %}
{% endblock %}
{% block content %}
<h1>
{% trans "Refund order" %}
<a class="btn btn-link btn-lg"
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
{% blocktrans trimmed with order=order.code %}
Back to order {{ order }}
{% endblocktrans %}
</a>
</h1>
<form method="post" href="">
{% csrf_token %}
<fieldset class="form-inline form-order-change">
<legend>{% trans "How should the refund be sent?" %}</legend>
<div class="table-responsive">
<table class="table table-condensed">
<thead>
<tr>
<th>#</th>
<th>{% trans "Payment confirmation date" %}</th>
<th>{% trans "Payment method" %}</th>
<th>{% trans "Amount not refunded" %}</th>
<th>{% trans "Refund" %}</th>
</tr>
</thead>
<tbody>
{% for p in payments %}
<tr>
<td>{{ p.full_id }}</td>
<td>{{ p.payment_date|date:"SHORT_DATETIME_FORMAT" }}</td>
<td>
{{ p.payment_provider.verbose_name }}
</td>
<td>{{ p.available_amount|money:request.event.currency }}</td>
<td>
{% if p.partial_refund_possible %}
{% trans "Automatically refund" context "amount_label" %}
<div class="input-group">
<input type="text" name="refund-{{ p.pk }}"
value="{{ p.propose_refund|floatformat:2 }}"
title="" class="form-control">
<span class="input-group-addon">
{{ request.event.currency }}
</span>
</div>
{% elif p.full_refund_possible %}
<label class="checkbox">
<input type="checkbox" name="refund-{{ p.pk }}"
value="{{ p.amount|floatformat:2 }}"
{% if p.propose_refund == p.amount %}checked{% endif %}>
{% trans "Automatically refund full amount" %}
</label>
{% else %}
<em>{% trans "This payment method does not support automatic refunds." %}</em>
{% endif %}
</td>
</tr>
{% endfor %}
<tr>
<td></td>
<td></td>
<td><strong>{% trans "Transfer to other order" %}</strong></td>
<td></td>
<td>
{% trans "Transfer" context "amount_label" %}
<div class="input-group">
<input type="text" name="refund-offsetting"
title="" class="form-control" value="{{ 0|floatformat:2 }}">
<span class="input-group-addon">
{{ request.event.currency }}
</span>
</div>
{% trans "to" context "order_label" %}
<input type="text" name="order-offsetting"
value="" title="" class="form-control">
</td>
</tr>
<tr>
<td></td>
<td></td>
<td><strong>{% trans "Manual refund" %}</strong></td>
<td></td>
<td>
{% trans "Manually refund" context "amount_label" %}
<div class="input-group">
<input type="text" name="refund-manual"
value="{{ remainder|floatformat:2 }}"
title="" class="form-control">
<span class="input-group-addon">
{{ request.event.currency }}
</span>
</div>
<label class="radio">
<input type="radio" name="manual_state" value="created" checked>
{% trans "Keep transfer as to do" %}
</label>
<label class="radio">
<input type="radio" name="manual_state" value="done">
{% trans "Mark refund as done" %}
</label>
</td>
</tr>
</tbody>
</table>
</div>
</fieldset>
<p>
{% blocktrans trimmed %}
Any payments that you selected for automatical refunds will be immediately communicate the refund
request to the respective payment provider. Manual refunds will be created as pending refunds, you
can then later mark them as done once you actually transferred the money back to the customer.
{% endblocktrans %}
</p>
<p>&nbsp;</p>
<input type="hidden" name="start-action" value="{{ start_form.cleaned_data.action }}">
<input type="hidden" name="start-mode" value="{{ start_form.cleaned_data.mode }}">
<input type="hidden" name="start-partial_amount" value="{{ partial_amount }}">
<div class="row checkout-button-row">
<div class="col-md-4">
<a class="btn btn-block btn-default btn-lg"
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
{% trans "Cancel" %}
</a>
</div>
<div class="col-md-4 col-md-offset-4">
<button class="btn btn-block btn-danger btn-lg" type="submit" name="perform" value="true">
{% trans "Perform refund" %}
</button>
</div>
<div class="clearfix"></div>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,34 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}
{% trans "Mark refund as done" %}
{% endblock %}
{% block content %}
<h1>
{% trans "Mark refund as done" %}
<a class="btn btn-link btn-lg"
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
{% blocktrans trimmed with order=order.code %}
Back to order {{ order }}
{% endblocktrans %}
</a>
</h1>
<form method="post" class="form-horizontal" href="">
{% csrf_token %}
<p>{% blocktrans trimmed %}
Do you really want to mark this refund as complete?
{% endblocktrans %}</p>
<div class="form-group submit-group">
<a class="btn btn-default btn-lg"
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
{% trans "Cancel" %}
</a>
<button class="btn btn-primary btn-save btn-lg" type="submit">
{% trans "Mark as done" %}
</button>
<div class="clearfix"></div>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,55 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load money %}
{% block title %}
{% trans "Mark refund as done" %}
{% endblock %}
{% block content %}
<h1>
{% trans "Mark refund as done" %}
<a class="btn btn-link btn-lg"
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
{% blocktrans trimmed with order=order.code %}
Back to order {{ order }}
{% endblocktrans %}
</a>
</h1>
<form method="post" class="" action="">
{% csrf_token %}
<p>
{% blocktrans trimmed with amount=refund.amount|money:request.event.currency method=refund.payment_provider.verbose_name pending=pending_sum|money:request.event.currency total=order.total|money:request.event.currency %}
We recevied notice that <strong>{{ amount }}</strong> have been refunded via
<strong>{{ method }}</strong>. If this refund is processed, the order will be underpaid by
<strong>{{ pending }}</strong>. The order total is <strong>{{ total }}</strong>.
{% endblocktrans %}
</p>
<p>
{% blocktrans trimmed with amount=refund.amount|money:request.event.currency method=refund.payment_provider.verbose_name %}
What should happen to the ticket order?
{% endblocktrans %}
</p>
<div class="form-inline">
<label class="radio">
<input type="radio" name="action" value="n" {% if not propose_cancel %}checked{% endif %}>
{% trans "Mark the order as unpaid and allow the customer to pay again with another payment method." %}
</label>
<br>
<label class="radio">
<input type="radio" name="action" value="r" {% if propose_cancel %}checked{% endif %}>
{% trans "Cancel the order irrevocably." %}
</label>
</div>
<div class="form-group submit-group">
<a class="btn btn-default btn-lg"
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
{% trans "Cancel" %}
</a>
<button class="btn btn-primary btn-save btn-lg" type="submit">
{% trans "Process refund" %}
</button>
<div class="clearfix"></div>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,60 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}
{% trans "Refund order" %}
{% endblock %}
{% block content %}
<h1>
{% trans "Refund order" %}
<a class="btn btn-link btn-lg"
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
{% blocktrans trimmed with order=order.code %}
Back to order {{ order }}
{% endblocktrans %}
</a>
</h1>
<form method="post" href="">
{% bootstrap_form_errors form %}
<fieldset class="form-inline form-order-change">
<legend>{% trans "How much do you want to refund?" %}</legend>
<div class="radio">
<label>
<input name="{{ form.prefix }}-mode" type="radio" value="full"
{% if not form.mode.value or form.mode.value == "full" %}checked="checked"{% endif %}>
{% trans "Refund full paid amount" %}
</label>
</div>
<div class="radio">
<label>
<input name="{{ form.prefix }}-mode" type="radio" value="partial"
{% if form.mode.value == "partial" %}checked="checked"{% endif %}>
{% trans "Refund only" %}
{% bootstrap_field form.partial_amount addon_after=request.event.currency layout='inline' %}
</label>
</div>
</fieldset>
<p>&nbsp;</p>
<fieldset class="form-inline form-order-change">
<legend>{% trans "What should happen to the order?" %}</legend>
{% bootstrap_field form.action layout='horizontal' horizontal_label_class='sr-only' horizontal_field_class='' %}
</fieldset>
<p>&nbsp;</p>
{% csrf_token %}
<div class="row checkout-button-row">
<div class="col-md-4">
<a class="btn btn-block btn-default btn-lg"
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
{% trans "Cancel" %}
</a>
</div>
<div class="col-md-4 col-md-offset-4">
<button class="btn btn-block btn-primary btn-lg" type="submit">
{% trans "Continue" %}
</button>
</div>
<div class="clearfix"></div>
</div>
</form>
{% endblock %}

View File

@@ -127,7 +127,19 @@
{% endif %}
</td>
<td>{{ o.datetime|date:"SHORT_DATETIME_FORMAT" }}</td>
<td class="text-right">{{ o.total|money:request.event.currency }}</td>
<td class="text-right">
{% if o.has_external_refund or o.has_pending_refund %}
<span class="label label-danger">{% trans "REFUND PENDING" %}</span>
{% elif o.has_pending_refund %}
<span class="label label-warning">{% trans "REFUND PENDING" %}</span>
{% endif %}
{% if o.is_overpaid %}
<span class="label label-warning">{% trans "OVERPAID" %}</span>
{% elif o.is_underpaid %}
<span class="label label-danger">{% trans "UNDERPAID" %}</span>
{% endif %}
{{ o.total|money:request.event.currency }}
</td>
<td class="text-right">{{ o.pcnt }}</td>
<td class="text-right">{% include "pretixcontrol/orders/fragment_order_status.html" with order=o %}</td>
</tr>

View File

@@ -0,0 +1,104 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load eventurl %}
{% load urlreplace %}
{% load money %}
{% load bootstrap3 %}
{% block title %}{% trans "Refunds" %}{% endblock %}
{% block content %}
<h1>{% trans "Refunds" %}</h1>
<div class="row filter-form">
<form class="" action="" method="get">
<div class="col-md-5 col-xs-6">
{% bootstrap_field filter_form.status layout='inline' %}
</div>
<div class="col-md-5 col-xs-6">
{% bootstrap_field filter_form.provider layout='inline' %}
</div>
<div class="col-md-2 col-xs-12">
<button class="btn btn-primary btn-block" type="submit">
<span class="fa fa-filter"></span>
<span class="hidden-md">
{% trans "Filter" %}
</span>
</button>
</div>
</form>
</div>
{% if not filter_form.filtered and refunds|length == 0 %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
No refunds are currently open.
{% endblocktrans %}
</p>
</div>
{% else %}
<div class="table-responsive">
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>#</th>
<th>{% trans "Payment provider" %}</th>
<th>{% trans "Start date" %}</th>
<th>{% trans "Source" %}</th>
<th>{% trans "Status" %}</th>
<th class="text-right">{% trans "Amount" %}</th>
<th class="text-right">{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for r in refunds %}
<tr>
<td>
<strong>
<a href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=r.order.code %}">
{{ r.order.code }}</a>-R-{{ r.local_id }}
</strong>
</td>
<td>
{{ r.payment_provider.verbose_name }}
</td>
<td>{{ o.created|date:"SHORT_DATETIME_FORMAT" }}</td>
<td>{{ r.get_source_display }}</td>
<td>
<span class="label label-{% if r.state == "external" or r.state == "transit" or r.state == "created" %}warning{% elif r.state == "done" %}success{% else %}danger{% endif %}">
{{ r.get_state_display }}
</span>
</td>
<td class="text-right">
{{ r.amount|money:request.event.currency }}
</td>
<td class="text-right">
{% if r.state == "transit" or r.state == "created" %}
<a href="{% url "control:event.order.refunds.cancel" event=request.event.slug organizer=request.event.organizer.slug code=r.order.code refund=r.pk %}?next={{ request.get_full_path|urlencode }}"
class="btn btn-danger btn-xs" data-toggle="tooltip">
<span class="fa fa-times"></span>
{% trans "Cancel" %}
</a>
<a href="{% url "control:event.order.refunds.done" event=request.event.slug organizer=request.event.organizer.slug code=r.order.code refund=r.pk %}?next={{ request.get_full_path|urlencode }}"
class="btn btn-primary btn-xs" data-toggle="tooltip">
<span class="fa fa-check"></span>
{% trans "Confirm as done" %}
</a>
{% elif r.state == "external" %}
<a href="{% url "control:event.order.refunds.cancel" event=request.event.slug organizer=request.event.organizer.slug code=r.order.code refund=r.pk %}?next={{ request.get_full_path|urlencode }}"
class="btn btn-default btn-xs" data-toggle="tooltip">
<span class="fa fa-times"></span>
{% trans "Ignore" %}
</a>
<a href="{% url "control:event.order.refunds.process" event=request.event.slug organizer=request.event.organizer.slug code=r.order.code refund=r.pk %}?next={{ request.get_full_path|urlencode }}"
class="btn btn-primary btn-xs" data-toggle="tooltip">
<span class="fa fa-check"></span>
{% trans "Process refund" %}
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% include "pretixcontrol/pagination.html" %}
{% endif %}
{% endblock %}

View File

@@ -198,12 +198,25 @@ urlpatterns = [
name='event.order.sendmail'),
url(r'^orders/(?P<code>[0-9A-Z]+)/mail_history$', orders.OrderEmailHistory.as_view(),
name='event.order.mail_history'),
url(r'^orders/(?P<code>[0-9A-Z]+)/payments/(?P<payment>\d+)/cancel$', orders.OrderPaymentCancel.as_view(),
name='event.order.payments.cancel'),
url(r'^orders/(?P<code>[0-9A-Z]+)/payments/(?P<payment>\d+)/confirm$', orders.OrderPaymentConfirm.as_view(),
name='event.order.payments.confirm'),
url(r'^orders/(?P<code>[0-9A-Z]+)/refund$', orders.OrderRefundView.as_view(),
name='event.order.refunds.start'),
url(r'^orders/(?P<code>[0-9A-Z]+)/refunds/(?P<refund>\d+)/cancel$', orders.OrderRefundCancel.as_view(),
name='event.order.refunds.cancel'),
url(r'^orders/(?P<code>[0-9A-Z]+)/refunds/(?P<refund>\d+)/process$', orders.OrderRefundProcess.as_view(),
name='event.order.refunds.process'),
url(r'^orders/(?P<code>[0-9A-Z]+)/refunds/(?P<refund>\d+)/done$', orders.OrderRefundDone.as_view(),
name='event.order.refunds.done'),
url(r'^orders/(?P<code>[0-9A-Z]+)/$', orders.OrderDetail.as_view(), name='event.order'),
url(r'^invoice/(?P<invoice>[^/]+)$', orders.InvoiceDownload.as_view(),
name='event.invoice.download'),
url(r'^orders/overview/$', orders.OverView.as_view(), name='event.orders.overview'),
url(r'^orders/export/$', orders.ExportView.as_view(), name='event.orders.export'),
url(r'^orders/export/do$', orders.ExportDoView.as_view(), name='event.orders.export.do'),
url(r'^orders/refunds/$', orders.RefundList.as_view(), name='event.orders.refunds'),
url(r'^orders/go$', orders.OrderGo.as_view(), name='event.orders.go'),
url(r'^orders/$', orders.OrderList.as_view(), name='event.orders'),
url(r'^shredder/$', shredder.StartShredView.as_view(), name='event.shredder.start'),

View File

@@ -17,7 +17,7 @@ from django.utils.timezone import now
from django.utils.translation import pgettext, ugettext_lazy as _, ungettext
from pretix.base.models import (
Item, Order, OrderPosition, RequiredAction, SubEvent, Voucher,
Item, Order, OrderPosition, OrderRefund, RequiredAction, SubEvent, Voucher,
WaitingListEntry,
)
from pretix.base.models.checkin import CheckinList
@@ -266,6 +266,16 @@ def event_index(request, organizer, event):
'comment_form': CommentForm(initial={'comment': request.event.comment})
}
ctx['has_overpaid_orders'] = Order.annotate_overpayments(request.event.orders).filter(
Q(~Q(status__in=[Order.STATUS_REFUNDED, Order.STATUS_CANCELED]) & Q(pending_sum_t__lt=0))
| Q(Q(status__in=[Order.STATUS_REFUNDED, Order.STATUS_CANCELED]) & Q(pending_sum_rc__lt=0))
| Q(Q(status__in=[Order.STATUS_EXPIRED, Order.STATUS_PENDING]) & Q(pending_sum_rc__lte=0))
).exists()
ctx['has_pending_refunds'] = OrderRefund.objects.filter(
order__event=request.event,
state__in=(OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_EXTERNAL)
)
for a in ctx['actions']:
a.display = a.display(request)

View File

@@ -1,7 +1,9 @@
import json
import logging
import mimetypes
import os
from datetime import timedelta
from decimal import Decimal, DecimalException
import pytz
import vat_moss.id
@@ -9,12 +11,14 @@ from django.conf import settings
from django.contrib import messages
from django.core.files import File
from django.core.urlresolvers import reverse
from django.db import transaction
from django.db.models import Count
from django.http import FileResponse, Http404, HttpResponseNotAllowed
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import formats
from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.safestring import mark_safe
from django.utils.http import is_safe_url
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from django.views.generic import (
@@ -29,8 +33,9 @@ from pretix.base.models import (
generate_position_secret, generate_secret,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import OrderFee
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
from pretix.base.models.tax import EU_COUNTRIES
from pretix.base.payment import PaymentException
from pretix.base.services.export import export
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_pdf, invoice_pdf_task,
@@ -40,17 +45,18 @@ from pretix.base.services.locking import LockTimeoutException
from pretix.base.services.mail import SendMailException, render_mail
from pretix.base.services.orders import (
OrderChangeManager, OrderError, cancel_order, extend_order,
mark_order_expired, mark_order_paid,
mark_order_expired, mark_order_refunded,
)
from pretix.base.services.stats import order_overview
from pretix.base.signals import register_data_exporters
from pretix.base.templatetags.money import money_filter
from pretix.base.views.async import AsyncAction
from pretix.base.views.mixins import OrderQuestionsViewMixin
from pretix.control.forms.filter import EventOrderFilterForm
from pretix.control.forms.filter import EventOrderFilterForm, RefundFilterForm
from pretix.control.forms.orders import (
CommentForm, ExporterForm, ExtendForm, MarkPaidForm, OrderContactForm,
OrderLocaleForm, OrderMailForm, OrderPositionAddForm,
OrderPositionChangeForm, OtherOperationsForm,
OrderPositionChangeForm, OrderRefundForm, OtherOperationsForm,
)
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.control.views import PaginationMixin
@@ -71,6 +77,9 @@ class OrderList(EventPermissionRequiredMixin, PaginationMixin, ListView):
qs = Order.objects.filter(
event=self.request.event
).annotate(pcnt=Count('positions', distinct=True)).select_related('invoice_address')
qs = Order.annotate_overpayments(qs)
if self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs)
@@ -116,10 +125,6 @@ class OrderView(EventPermissionRequiredMixin, DetailView):
)
return ctx
@cached_property
def payment_provider(self):
return self.request.event.get_payment_providers().get(self.order.payment_provider)
def get_order_url(self):
return reverse('control:event.order', kwargs={
'event': self.request.event.slug,
@@ -136,19 +141,19 @@ class OrderDetail(OrderView):
ctx = super().get_context_data(**kwargs)
ctx['items'] = self.get_items()
ctx['event'] = self.request.event
ctx['payment_provider'] = self.payment_provider
if self.payment_provider:
ctx['payment'] = self.payment_provider.order_control_render(self.request, self.object)
else:
ctx['payment'] = mark_safe('<div class="alert alert-danger">{}</div>'.format(
_('This order was paid using a payment provider plugin that is now disabled or uninstalled.')
))
ctx['payments'] = self.order.payments.order_by('-created')
ctx['refunds'] = self.order.refunds.select_related('payment').order_by('-created')
for p in ctx['payments']:
if p.payment_provider:
p.html_info = (p.payment_provider.payment_control_render(self.request, p) or "").strip()
ctx['invoices'] = list(self.order.invoices.all().select_related('event'))
ctx['comment_form'] = CommentForm(initial={
'comment': self.order.comment,
'checkin_attention': self.order.checkin_attention
})
ctx['display_locale'] = dict(settings.LANGUAGES)[self.object.locale or self.request.event.settings.locale]
ctx['overpaid'] = self.order.pending_sum * -1
return ctx
def get_items(self):
@@ -223,6 +228,390 @@ class OrderComment(OrderView):
return HttpResponseNotAllowed(['POST'])
class OrderPaymentCancel(OrderView):
permission = 'can_change_orders'
@cached_property
def payment(self):
return get_object_or_404(self.order.payments, pk=self.kwargs['payment'])
def post(self, *args, **kwargs):
if self.payment.state in (OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING):
with transaction.atomic():
self.payment.state = OrderPayment.PAYMENT_STATE_CANCELED
self.payment.save()
self.order.log_action('pretix.event.order.payment.canceled', {
'local_id': self.payment.local_id,
'provider': self.payment.provider,
}, user=self.request.user)
messages.success(self.request, _('This payment has been canceled.'))
else:
messages.error(self.request, _('This payment can not be canceled at the moment.'))
return redirect(self.get_order_url())
def get(self, *args, **kwargs):
return render(self.request, 'pretixcontrol/order/pay_cancel.html', {
'order': self.order,
})
class OrderRefundCancel(OrderView):
permission = 'can_change_orders'
@cached_property
def refund(self):
return get_object_or_404(self.order.refunds, pk=self.kwargs['refund'])
def post(self, *args, **kwargs):
if self.refund.state in (OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT,
OrderRefund.REFUND_STATE_EXTERNAL):
with transaction.atomic():
self.refund.state = OrderRefund.REFUND_STATE_CANCELED
self.refund.save()
self.order.log_action('pretix.event.order.refund.canceled', {
'local_id': self.refund.local_id,
'provider': self.refund.provider,
}, user=self.request.user)
messages.success(self.request, _('The refund has been canceled.'))
else:
messages.error(self.request, _('This refund can not be canceled at the moment.'))
if "next" in self.request.GET and is_safe_url(self.request.GET.get("next")):
return redirect(self.request.GET.get("next"))
return redirect(self.get_order_url())
def get(self, *args, **kwargs):
return render(self.request, 'pretixcontrol/order/refund_cancel.html', {
'order': self.order,
})
class OrderRefundProcess(OrderView):
permission = 'can_change_orders'
@cached_property
def refund(self):
return get_object_or_404(self.order.refunds, pk=self.kwargs['refund'])
def post(self, *args, **kwargs):
if self.refund.state == OrderRefund.REFUND_STATE_EXTERNAL:
self.refund.done(user=self.request.user)
if self.request.POST.get("action") == "r":
mark_order_refunded(self.order, user=self.request.user)
else:
self.order.status = Order.STATUS_PENDING
self.order.set_expires(
now(),
self.order.event.subevents.filter(
id__in=self.order.positions.values_list('subevent_id', flat=True))
)
self.order.save()
messages.success(self.request, _('The refund has been processed.'))
else:
messages.error(self.request, _('This refund can not be processed at the moment.'))
if "next" in self.request.GET and is_safe_url(self.request.GET.get("next")):
return redirect(self.request.GET.get("next"))
return redirect(self.get_order_url())
def get(self, *args, **kwargs):
return render(self.request, 'pretixcontrol/order/refund_process.html', {
'order': self.order,
'refund': self.refund,
'pending_sum': self.order.pending_sum + self.refund.amount,
'propose_cancel': self.order.pending_sum + self.refund.amount >= self.order.total
})
class OrderRefundDone(OrderView):
permission = 'can_change_orders'
@cached_property
def refund(self):
return get_object_or_404(self.order.refunds, pk=self.kwargs['refund'])
def post(self, *args, **kwargs):
if self.refund.state in (OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT):
self.refund.done(user=self.request.user)
messages.success(self.request, _('The refund has been marked as done.'))
else:
messages.error(self.request, _('This refund can not be processed at the moment.'))
if "next" in self.request.GET and is_safe_url(self.request.GET.get("next")):
return redirect(self.request.GET.get("next"))
return redirect(self.get_order_url())
def get(self, *args, **kwargs):
return render(self.request, 'pretixcontrol/order/refund_done.html', {
'order': self.order,
})
class OrderPaymentConfirm(OrderView):
permission = 'can_change_orders'
@cached_property
def payment(self):
return get_object_or_404(self.order.payments, pk=self.kwargs['payment'])
@cached_property
def mark_paid_form(self):
return MarkPaidForm(
instance=self.order,
data=self.request.POST if self.request.method == "POST" else None,
)
def post(self, *args, **kwargs):
if self.payment.state in (OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING):
if not self.mark_paid_form.is_valid():
return render(self.request, 'pretixcontrol/order/pay_complete.html', {
'form': self.mark_paid_form,
'order': self.order,
})
try:
self.payment.confirm(user=self.request.user,
count_waitinglist=False,
force=self.mark_paid_form.cleaned_data.get('force', False))
except Quota.QuotaExceededException as e:
messages.error(self.request, str(e))
except PaymentException as e:
messages.error(self.request, str(e))
except SendMailException:
messages.warning(self.request,
_('The payment has been marked as complete, but we were unable to send a '
'confirmation mail.'))
else:
messages.success(self.request, _('The payment has been marked as complete.'))
else:
messages.error(self.request, _('This payment can not be confirmed at the moment.'))
return redirect(self.get_order_url())
def get(self, *args, **kwargs):
return render(self.request, 'pretixcontrol/order/pay_complete.html', {
'form': self.mark_paid_form,
'order': self.order,
})
class OrderRefundView(OrderView):
permission = 'can_change_orders'
@cached_property
def start_form(self):
return OrderRefundForm(
order=self.order,
data=self.request.POST if self.request.method == "POST" else None,
prefix='start',
initial={
'partial_amount': self.order.total - self.order.pending_sum,
'action': (
'mark_pending' if self.order.status == Order.STATUS_PAID
else 'do_nothing'
)
}
)
def post(self, *args, **kwargs):
if self.start_form.is_valid():
payments = self.order.payments.filter(
state=OrderPayment.PAYMENT_STATE_CONFIRMED
)
for p in payments:
p.full_refund_possible = p.payment_provider.payment_refund_supported(p)
p.partial_refund_possible = p.payment_provider.payment_partial_refund_supported(p)
p.propose_refund = Decimal('0.00')
p.available_amount = p.amount - p.refunded_amount
unused_payments = set(p for p in payments if p.full_refund_possible or p.partial_refund_possible)
# Algorithm to choose which payments are to be refunded to create the least hassle
if self.start_form.cleaned_data.get('mode') == 'full':
to_refund = full_refund = self.order.total - self.order.pending_sum
else:
to_refund = full_refund = self.start_form.cleaned_data.get('partial_amount')
while to_refund and unused_payments:
bigger = sorted([p for p in unused_payments if p.available_amount > to_refund],
key=lambda p: p.available_amount)
same = [p for p in unused_payments if p.available_amount == to_refund]
smaller = sorted([p for p in unused_payments if p.available_amount < to_refund],
key=lambda p: p.available_amount,
reverse=True)
if same:
for payment in same:
if payment.full_refund_possible or payment.partial_refund_possible:
payment.propose_refund = payment.available_amount
to_refund -= payment.available_amount
unused_payments.remove(payment)
break
elif bigger:
for payment in bigger:
if payment.partial_refund_possible:
payment.propose_refund = to_refund
to_refund -= to_refund
unused_payments.remove(payment)
break
elif smaller:
for payment in smaller:
if payment.full_refund_possible or payment.partial_refund_possible:
payment.propose_refund = payment.available_amount
to_refund -= payment.available_amount
unused_payments.remove(payment)
break
if 'perform' in self.request.POST:
refund_selected = Decimal('0.00')
refunds = []
is_valid = True
manual_value = self.request.POST.get('refund-manual', '0') or '0'
manual_value = formats.sanitize_separators(manual_value)
try:
manual_value = Decimal(manual_value)
except (DecimalException, TypeError) as e:
messages.error(self.request, _('You entered an invalid number.'))
is_valid = False
else:
refund_selected += manual_value
if manual_value:
refunds.append(OrderRefund(
order=self.order,
payment=None,
source=OrderRefund.REFUND_SOURCE_ADMIN,
state=(
OrderRefund.REFUND_STATE_DONE
if self.request.POST.get('manual_state') == 'done'
else OrderRefund.REFUND_STATE_CREATED
),
amount=manual_value,
provider='manual'
))
offsetting_value = self.request.POST.get('refund-offsetting', '0') or '0'
offsetting_value = formats.sanitize_separators(offsetting_value)
try:
offsetting_value = Decimal(offsetting_value)
except (DecimalException, TypeError) as e:
messages.error(self.request, _('You entered an invalid number.'))
is_valid = False
else:
if offsetting_value:
refund_selected += offsetting_value
try:
order = Order.objects.get(code=self.request.POST.get('order-offsetting'))
except Order.DoesNotExist:
messages.error(self.request, _('You entered an order that could not be found.'))
is_valid = False
else:
refunds.append(OrderRefund(
order=self.order,
payment=None,
source=OrderRefund.REFUND_SOURCE_ADMIN,
state=OrderRefund.REFUND_STATE_DONE,
execution_date=now(),
amount=offsetting_value,
provider='offsetting',
info=json.dumps({
'orders': [order.code]
})
))
for p in payments:
value = self.request.POST.get('refund-{}'.format(p.pk), '0') or '0'
value = formats.sanitize_separators(value)
try:
value = Decimal(value)
except (DecimalException, TypeError) as e:
messages.error(self.request, _('You entered an invalid number.'))
is_valid = False
else:
if value == 0:
continue
elif value > p.available_amount:
messages.error(self.request, _('You can not refund more than the amount of a '
'payment that is not yet refunded.'))
is_valid = False
break
elif value != p.amount and not p.partial_refund_possible:
messages.error(self.request, _('You selected a partial refund for a payment method that '
'only supports full refunds.'))
is_valid = False
break
elif (p.partial_refund_possible or p.full_refund_possible) and value > 0:
refund_selected += value
refunds.append(OrderRefund(
order=self.order,
payment=p,
source=OrderRefund.REFUND_SOURCE_ADMIN,
state=OrderRefund.REFUND_STATE_CREATED,
amount=value,
provider=p.provider
))
any_success = False
if refund_selected == full_refund and is_valid:
for r in refunds:
r.save()
if r.payment or r.provider == "offsetting":
try:
r.payment_provider.execute_refund(r)
except PaymentException as e:
r.state = OrderRefund.REFUND_STATE_FAILED
r.save()
messages.error(self.request, _('One of the refunds failed to be processed. You should '
'retry to refund in a different way. The error message '
'was: {}').format(str(e)))
else:
any_success = True
if r.state == OrderRefund.REFUND_STATE_DONE:
messages.success(self.request, _('A refund of {} has been processed.').format(
money_filter(r.amount, self.request.event.currency)
))
elif r.state == OrderRefund.REFUND_STATE_CREATED:
messages.info(self.request, _('A refund of {} has been saved, but not yet '
'fully executed. You can mark it as complete '
'below.').format(
money_filter(r.amount, self.request.event.currency)
))
else:
any_success = True
self.order.log_action('pretix.event.order.refund.created', {
'local_id': r.local_id,
'provider': r.provider,
}, user=self.request.user)
if any_success:
if self.start_form.cleaned_data.get('action') == 'mark_refunded':
mark_order_refunded(self.order, user=self.request.user)
elif self.start_form.cleaned_data.get('action') == 'mark_pending':
self.order.status = Order.STATUS_PENDING
self.order.set_expires(
now(),
self.order.event.subevents.filter(
id__in=self.order.positions.values_list('subevent_id', flat=True))
)
self.order.save()
return redirect(self.get_order_url())
else:
messages.error(self.request, _('The refunds you selected do not match the selected total refund '
'amount.'))
return render(self.request, 'pretixcontrol/order/refund_choose.html', {
'payments': payments,
'remainder': to_refund,
'order': self.order,
'partial_amount': self.request.POST.get('start-partial_amount'),
'start_form': self.start_form
})
return self.get(*args, **kwargs)
def get(self, *args, **kwargs):
return render(self.request, 'pretixcontrol/order/refund_start.html', {
'form': self.start_form,
'order': self.order,
})
class OrderTransition(OrderView):
permission = 'can_change_orders'
@@ -235,19 +624,35 @@ class OrderTransition(OrderView):
def post(self, *args, **kwargs):
to = self.request.POST.get('status', '')
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and to == 'p':
if not self.mark_paid_form.is_valid():
return render(self.request, 'pretixcontrol/order/pay.html', {
'form': self.mark_paid_form,
'order': self.order,
})
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and to == 'p' and self.mark_paid_form.is_valid():
ps = self.order.pending_sum
try:
mark_order_paid(self.order, manual=True, user=self.request.user,
count_waitinglist=False, force=self.mark_paid_form.cleaned_data.get('force', False))
p = self.order.payments.get(
state__in=(OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED),
provider='manual',
amount=ps
)
except OrderPayment.DoesNotExist:
self.order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_PENDING,
OrderPayment.PAYMENT_STATE_CREATED)) \
.update(state=OrderPayment.PAYMENT_STATE_CANCELED)
p = self.order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED,
provider='manual',
amount=ps,
fee=None
)
try:
p.confirm(user=self.request.user, count_waitinglist=False,
force=self.mark_paid_form.cleaned_data.get('force', False))
except Quota.QuotaExceededException as e:
messages.error(self.request, str(e))
except PaymentException as e:
messages.error(self.request, str(e))
except SendMailException:
messages.warning(self.request, _('The order has been marked as paid, but we were unable to send a confirmation mail.'))
messages.warning(self.request, _('The order has been marked as paid, but we were unable to send a '
'confirmation mail.'))
else:
messages.success(self.request, _('The order has been marked as paid.'))
elif self.order.cancel_allowed() and to == 'c':
@@ -255,20 +660,12 @@ class OrderTransition(OrderView):
messages.success(self.request, _('The order has been canceled.'))
elif self.order.status == Order.STATUS_PAID and to == 'n':
self.order.status = Order.STATUS_PENDING
self.order.payment_manual = True
self.order.save()
self.order.log_action('pretix.event.order.unpaid', user=self.request.user)
messages.success(self.request, _('The order has been marked as not paid.'))
elif self.order.status == Order.STATUS_PENDING and to == 'e':
mark_order_expired(self.order, user=self.request.user)
messages.success(self.request, _('The order has been marked as expired.'))
elif self.order.status == Order.STATUS_PAID and to == 'r':
if not self.payment_provider:
messages.error(self.request, _('This order is not assigned to a known payment provider.'))
else:
ret = self.payment_provider.order_control_refund_perform(self.request, self.order)
if ret:
return redirect(ret)
return redirect(self.get_order_url())
def get(self, *args, **kwargs):
@@ -282,20 +679,6 @@ class OrderTransition(OrderView):
return render(self.request, 'pretixcontrol/order/cancel.html', {
'order': self.order,
})
elif self.order.status == Order.STATUS_PAID and to == 'r':
if not self.payment_provider:
messages.error(self.request, _('This order is not assigned to a known payment provider.'))
return redirect(self.get_order_url())
try:
cr = self.payment_provider.order_control_refund_render(self.order, self.request)
except TypeError:
cr = self.payment_provider.order_control_refund_render(self.order)
return render(self.request, 'pretixcontrol/order/refund.html', {
'order': self.order,
'payment': cr,
})
else:
return HttpResponseNotAllowed(['POST'])
@@ -700,9 +1083,7 @@ class OrderModifyInformation(OrderQuestionsViewMixin, OrderView):
self.order.log_action('pretix.event.order.modified', {
'invoice_data': self.invoice_form.cleaned_data,
'data': [{
k: (f.cleaned_data.get(k).name
if isinstance(f.cleaned_data.get(k), File)
else f.cleaned_data.get(k))
k: (f.cleaned_data.get(k).name if isinstance(f.cleaned_data.get(k), File) else f.cleaned_data.get(k))
for k in f.changed_data
} for f in self.forms]
}, user=request.user)
@@ -878,7 +1259,8 @@ class OrderSendMail(EventPermissionRequiredMixin, OrderViewMixin, FormView):
email_context, 'pretix.event.order.email.custom_sent',
self.request.user
)
messages.success(self.request, _('Your message has been queued and will be sent to {}.'.format(order.email)))
messages.success(self.request,
_('Your message has been queued and will be sent to {}.'.format(order.email)))
except SendMailException:
messages.error(
self.request,
@@ -890,8 +1272,8 @@ class OrderSendMail(EventPermissionRequiredMixin, OrderViewMixin, FormView):
return reverse('control:event.order', kwargs={
'event': self.request.event.slug,
'organizer': self.request.event.organizer.slug,
'code': self.kwargs['code']}
)
'code': self.kwargs['code']
})
def get_context_data(self, *args, **kwargs):
ctx = super().get_context_data(*args, **kwargs)
@@ -988,7 +1370,6 @@ class OrderGo(EventPermissionRequiredMixin, View):
class ExportMixin:
@cached_property
def exporters(self):
exporters = []
@@ -1065,3 +1446,30 @@ class ExportView(EventPermissionRequiredMixin, ExportMixin, TemplateView):
ctx = super().get_context_data(**kwargs)
ctx['exporters'] = self.exporters
return ctx
class RefundList(EventPermissionRequiredMixin, PaginationMixin, ListView):
model = OrderRefund
context_object_name = 'refunds'
template_name = 'pretixcontrol/orders/refunds.html'
permission = 'can_view_orders'
def get_queryset(self):
qs = OrderRefund.objects.filter(
order__event=self.request.event
).select_related('order')
if self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs)
return qs.distinct()
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['filter_form'] = self.filter_form
return ctx
@cached_property
def filter_form(self):
return RefundFilterForm(data=self.request.GET, event=self.request.event,
initial={'status': 'open'})