mirror of
https://github.com/pretix/pretix.git
synced 2026-05-06 15:24:02 +00:00
Self-service refund form (#1135)
* Auto-refund * Add missing template * Notification for requested refund * Model-level tests * Add front-end tests * Default to notify
This commit is contained in:
31
src/pretix/base/migrations/0106_auto_20190118_1527.py
Normal file
31
src/pretix/base/migrations/0106_auto_20190118_1527.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 2.1.5 on 2019-01-18 15:27
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def enable_notifications_for_everyone(apps, schema_editor):
|
||||
NotificationSetting = apps.get_model('pretixbase', 'NotificationSetting')
|
||||
User = apps.get_model('pretixbase', 'User')
|
||||
create = []
|
||||
for u in User.objects.iterator():
|
||||
create.append(NotificationSetting(
|
||||
user=u,
|
||||
action_type='pretix.event.order.refund.requested',
|
||||
event=None,
|
||||
method='mail',
|
||||
enabled=True
|
||||
))
|
||||
if len(create) > 200:
|
||||
NotificationSetting.objects.bulk_create(create)
|
||||
create.clear()
|
||||
NotificationSetting.objects.bulk_create(create)
|
||||
create.clear()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('pretixbase', '0105_auto_20190112_1512'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(enable_notifications_for_everyone, migrations.RunPython.noop)
|
||||
]
|
||||
@@ -114,7 +114,15 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.email = self.email.lower()
|
||||
is_new = not self.pk
|
||||
super().save(*args, **kwargs)
|
||||
if is_new:
|
||||
self.notification_settings.create(
|
||||
action_type='pretix.event.order.refund.requested',
|
||||
event=None,
|
||||
method='mail',
|
||||
enabled=True
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.email
|
||||
|
||||
@@ -28,6 +28,7 @@ from django_countries.fields import CountryField
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from jsonfallback.fields import FallbackJSONField
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import User
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
@@ -356,9 +357,106 @@ class Order(LockModel, LoggedModel):
|
||||
|
||||
def cancel_allowed(self):
|
||||
return (
|
||||
self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and self.positions.exists()
|
||||
self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and self.count_positions
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def user_cancel_deadline(self):
|
||||
if self.status == Order.STATUS_PAID and self.total != Decimal('0.00'):
|
||||
until = self.event.settings.get('cancel_allow_user_paid_until', as_type=RelativeDateWrapper)
|
||||
else:
|
||||
until = self.event.settings.get('cancel_allow_user_until', as_type=RelativeDateWrapper)
|
||||
if until:
|
||||
if self.event.has_subevents:
|
||||
return min([
|
||||
until.datetime(se)
|
||||
for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True))
|
||||
])
|
||||
else:
|
||||
return until.datetime(self.event)
|
||||
|
||||
@cached_property
|
||||
def user_cancel_fee(self):
|
||||
fee = Decimal('0.00')
|
||||
if self.event.settings.cancel_allow_user_paid_keep:
|
||||
fee += self.event.settings.cancel_allow_user_paid_keep
|
||||
if self.event.settings.cancel_allow_user_paid_keep_percentage:
|
||||
fee += self.event.settings.cancel_allow_user_paid_keep_percentage / Decimal('100.0') * self.total
|
||||
if self.event.settings.cancel_allow_user_paid_keep_fees:
|
||||
fee += self.fees.filter(
|
||||
fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE)
|
||||
).aggregate(
|
||||
s=Sum('value')
|
||||
)['s'] or 0
|
||||
return round_decimal(fee, self.event.currency)
|
||||
|
||||
@property
|
||||
def user_cancel_allowed(self) -> bool:
|
||||
"""
|
||||
Returns whether or not this order can be canceled by the user.
|
||||
"""
|
||||
positions = list(self.positions.all().select_related('item'))
|
||||
cancelable = all([op.item.allow_cancel for op in positions])
|
||||
if not cancelable or not positions:
|
||||
return False
|
||||
if self.user_cancel_deadline and now() > self.user_cancel_deadline:
|
||||
return False
|
||||
if self.status == Order.STATUS_PENDING:
|
||||
return self.event.settings.cancel_allow_user
|
||||
elif self.status == Order.STATUS_PAID:
|
||||
if self.total == Decimal('0.00'):
|
||||
return self.event.settings.cancel_allow_user
|
||||
return self.event.settings.cancel_allow_user_paid
|
||||
return False
|
||||
|
||||
def propose_auto_refunds(self, amount: Decimal, payments: list=None):
|
||||
# Algorithm to choose which payments are to be refunded to create the least hassle
|
||||
payments = payments or self.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)
|
||||
to_refund = amount
|
||||
proposals = {}
|
||||
|
||||
while to_refund and unused_payments:
|
||||
bigger = sorted([
|
||||
p for p in unused_payments
|
||||
if p.available_amount > to_refund
|
||||
and p.partial_refund_possible
|
||||
], key=lambda p: p.available_amount)
|
||||
same = [
|
||||
p for p in unused_payments
|
||||
if p.available_amount == to_refund
|
||||
and (p.full_refund_possible or p.partial_refund_possible)
|
||||
]
|
||||
smaller = sorted([
|
||||
p for p in unused_payments
|
||||
if p.available_amount < to_refund
|
||||
and (p.full_refund_possible or p.partial_refund_possible)
|
||||
], key=lambda p: p.available_amount, reverse=True)
|
||||
if same:
|
||||
payment = same[0]
|
||||
proposals[payment] = payment.available_amount
|
||||
to_refund -= payment.available_amount
|
||||
unused_payments.remove(payment)
|
||||
elif bigger:
|
||||
payment = bigger[0]
|
||||
proposals[payment] = to_refund
|
||||
to_refund -= to_refund
|
||||
unused_payments.remove(payment)
|
||||
elif smaller:
|
||||
payment = smaller[0]
|
||||
proposals[payment] = payment.available_amount
|
||||
to_refund -= payment.available_amount
|
||||
unused_payments.remove(payment)
|
||||
else:
|
||||
break
|
||||
return proposals
|
||||
|
||||
@staticmethod
|
||||
def normalize_code(code):
|
||||
tr = str.maketrans({
|
||||
@@ -411,18 +509,6 @@ class Order(LockModel, LoggedModel):
|
||||
|
||||
return False # nothing there to modify
|
||||
|
||||
@property
|
||||
def can_user_cancel(self) -> bool:
|
||||
"""
|
||||
Returns whether or not this order can be canceled by the user.
|
||||
"""
|
||||
positions = self.positions.all().select_related('item')
|
||||
cancelable = all([op.item.allow_cancel for op in positions])
|
||||
return (
|
||||
self.status == Order.STATUS_PENDING
|
||||
or (self.status == Order.STATUS_PAID and self.total == Decimal('0.00'))
|
||||
) and self.event.settings.cancel_allow_user and cancelable and self.positions.exists()
|
||||
|
||||
@property
|
||||
def is_expired_by_time(self):
|
||||
return (
|
||||
|
||||
@@ -241,6 +241,12 @@ def register_default_notification_types(sender, **kwargs):
|
||||
_('External refund of payment'),
|
||||
_('An external refund for {order.code} has occurred.')
|
||||
),
|
||||
ParametrizedOrderNotificationType(
|
||||
sender,
|
||||
'pretix.event.order.refund.requested',
|
||||
_('Refund requested'),
|
||||
_('You have been requested to issue a refund for {order.code}.')
|
||||
),
|
||||
ActionRequiredNotificationType(
|
||||
sender,
|
||||
)
|
||||
|
||||
@@ -31,7 +31,7 @@ from pretix.base.models.orders import (
|
||||
)
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.base.models.tax import TaxedPrice
|
||||
from pretix.base.payment import BasePaymentProvider
|
||||
from pretix.base.payment import BasePaymentProvider, PaymentException
|
||||
from pretix.base.services.invoices import (
|
||||
generate_cancellation, generate_invoice, invoice_qualified,
|
||||
)
|
||||
@@ -1364,11 +1364,59 @@ def perform_order(self, event: str, payment_provider: str, positions: List[str],
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
|
||||
device=None, cancellation_fee=None):
|
||||
device=None, cancellation_fee=None, try_auto_refund=False):
|
||||
try:
|
||||
try:
|
||||
return _cancel_order(order, user, send_mail, api_token, device, oauth_application,
|
||||
cancellation_fee)
|
||||
ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application,
|
||||
cancellation_fee)
|
||||
if try_auto_refund:
|
||||
notify_admin = False
|
||||
error = False
|
||||
order = Order.objects.get(pk=order)
|
||||
refund_amount = order.pending_sum * -1
|
||||
proposals = order.propose_auto_refunds(refund_amount)
|
||||
can_auto_refund = sum(proposals.values()) == refund_amount
|
||||
if can_auto_refund:
|
||||
for p, value in proposals.items():
|
||||
with transaction.atomic():
|
||||
r = order.refunds.create(
|
||||
payment=p,
|
||||
source=OrderRefund.REFUND_SOURCE_BUYER,
|
||||
state=OrderRefund.REFUND_STATE_CREATED,
|
||||
amount=value,
|
||||
provider=p.provider
|
||||
)
|
||||
order.log_action('pretix.event.order.refund.created', {
|
||||
'local_id': r.local_id,
|
||||
'provider': r.provider,
|
||||
})
|
||||
|
||||
try:
|
||||
r.payment_provider.execute_refund(r)
|
||||
except PaymentException as e:
|
||||
with transaction.atomic():
|
||||
r.state = OrderRefund.REFUND_STATE_FAILED
|
||||
r.save()
|
||||
order.log_action('pretix.event.order.refund.failed', {
|
||||
'local_id': r.local_id,
|
||||
'provider': r.provider,
|
||||
'error': str(e)
|
||||
})
|
||||
error = True
|
||||
notify_admin = True
|
||||
else:
|
||||
if r.state != OrderRefund.REFUND_STATE_DONE:
|
||||
notify_admin = True
|
||||
else:
|
||||
notify_admin = True
|
||||
|
||||
if notify_admin:
|
||||
order.log_action('pretix.event.order.refund.requested')
|
||||
if error:
|
||||
raise OrderError(
|
||||
_('There was an error while trying to send the money back to you. Please contact the event organizer for further information.')
|
||||
)
|
||||
return ret
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
@@ -224,6 +225,30 @@ DEFAULTS = {
|
||||
'default': 'True',
|
||||
'type': bool
|
||||
},
|
||||
'cancel_allow_user_until': {
|
||||
'default': None,
|
||||
'type': RelativeDateWrapper,
|
||||
},
|
||||
'cancel_allow_user_paid': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
},
|
||||
'cancel_allow_user_paid_keep': {
|
||||
'default': '0.00',
|
||||
'type': Decimal,
|
||||
},
|
||||
'cancel_allow_user_paid_keep_fees': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
},
|
||||
'cancel_allow_user_paid_keep_percentage': {
|
||||
'default': '0.00',
|
||||
'type': Decimal,
|
||||
},
|
||||
'cancel_allow_user_paid_until': {
|
||||
'default': None,
|
||||
'type': RelativeDateWrapper,
|
||||
},
|
||||
'contact_mail': {
|
||||
'default': None,
|
||||
'type': str
|
||||
|
||||
@@ -435,6 +435,39 @@ class EventSettingsForm(SettingsForm):
|
||||
)
|
||||
|
||||
|
||||
class CancelSettingsForm(SettingsForm):
|
||||
cancel_allow_user = forms.BooleanField(
|
||||
label=_("Customers can cancel their unpaid orders"),
|
||||
required=False
|
||||
)
|
||||
cancel_allow_user_until = RelativeDateTimeField(
|
||||
label=_("Do not allow cancellations after"),
|
||||
required=False
|
||||
)
|
||||
cancel_allow_user_paid = forms.BooleanField(
|
||||
label=_("Customers can cancel their paid orders"),
|
||||
help_text=_("Paid money will be automatically paid back if the payment method allows it. "
|
||||
"Otherwise, a manual refund will be created for you to process manually."),
|
||||
required=False
|
||||
)
|
||||
cancel_allow_user_paid_keep = forms.DecimalField(
|
||||
label=_("Keep a fixed cancellation fee"),
|
||||
required=False
|
||||
)
|
||||
cancel_allow_user_paid_keep_fees = forms.BooleanField(
|
||||
label=_("Keep payment, shipping and service fees"),
|
||||
required=False
|
||||
)
|
||||
cancel_allow_user_paid_keep_percentage = forms.DecimalField(
|
||||
label=_("Keep a percentual cancellation fee"),
|
||||
required=False
|
||||
)
|
||||
cancel_allow_user_paid_until = RelativeDateTimeField(
|
||||
label=_("Do not allow cancellations after"),
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class PaymentSettingsForm(SettingsForm):
|
||||
payment_term_days = forms.IntegerField(
|
||||
label=_('Payment term in days'),
|
||||
|
||||
@@ -212,8 +212,10 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.event.order.quotaexceeded': _('The order could not be marked as paid: {message}'),
|
||||
'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.requested': _('The customer requested you to issue a refund.'),
|
||||
'pretix.event.order.refund.done': _('Refund {local_id} has been completed.'),
|
||||
'pretix.event.order.refund.canceled': _('Refund {local_id} has been canceled.'),
|
||||
'pretix.event.order.refund.failed': _('Refund {local_id} has failed.'),
|
||||
'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.'),
|
||||
|
||||
@@ -88,6 +88,14 @@ def get_event_navigation(request: HttpRequest):
|
||||
}),
|
||||
'active': url.url_name == 'event.settings.invoice',
|
||||
},
|
||||
{
|
||||
'label': pgettext_lazy('action', 'Cancellation'),
|
||||
'url': reverse('control:event.settings.cancel', kwargs={
|
||||
'event': request.event.slug,
|
||||
'organizer': request.event.organizer.slug,
|
||||
}),
|
||||
'active': url.url_name == 'event.settings.cancel',
|
||||
},
|
||||
{
|
||||
'label': _('Widget'),
|
||||
'url': reverse('control:event.settings.widget', kwargs={
|
||||
|
||||
40
src/pretix/control/templates/pretixcontrol/event/cancel.html
Normal file
40
src/pretix/control/templates/pretixcontrol/event/cancel.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{% extends "pretixcontrol/event/settings_base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block inside %}
|
||||
<h1>{% trans "Cancellation settings" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
<fieldset>
|
||||
<legend>{% trans "Cancellation of unpaid or free orders" %}</legend>
|
||||
{% bootstrap_field form.cancel_allow_user layout="control" %}
|
||||
{% bootstrap_field form.cancel_allow_user_until layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Cancellation of paid orders" %}</legend>
|
||||
{% bootstrap_field form.cancel_allow_user_paid layout="control" %}
|
||||
{% bootstrap_field form.cancel_allow_user_paid_keep layout="control" %}
|
||||
{% bootstrap_field form.cancel_allow_user_paid_keep_percentage layout="control" %}
|
||||
{% bootstrap_field form.cancel_allow_user_paid_keep_fees layout="control" %}
|
||||
{% bootstrap_field form.cancel_allow_user_paid_until layout="control" %}
|
||||
{% if not gets_notification %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
If a user requests cancels a paid order and the money can not be refunded automatically, e.g.
|
||||
due to the selected payment method, you will need to take manual action. However, you have
|
||||
currently turned off notifications for this event.
|
||||
{% endblocktrans %}
|
||||
<a href="{% url "control:user.settings.notifications" %}" class="btn btn-default">
|
||||
{% trans "Change notification settings" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -150,7 +150,7 @@
|
||||
<dd>
|
||||
{% for i in invoices %}
|
||||
<a href="{% url "control:event.invoice.download" invoice=i.pk event=request.event.slug organizer=request.event.organizer.slug %}">
|
||||
{% if i.is_cancellation %}{% trans "Cancellation" %}{% else %}{% trans "Invoice" %}{% endif %}
|
||||
{% if i.is_cancellation %}{% trans "Cancellation" context "invoice" %}{% else %}{% trans "Invoice" %}{% endif %}
|
||||
{{ i.number }}</a> ({{ i.date|date:"SHORT_DATE_FORMAT" }})
|
||||
{% if not i.canceled %}
|
||||
<form class="form-inline helper-display-inline" method="post"
|
||||
|
||||
@@ -124,6 +124,7 @@ urlpatterns = [
|
||||
url(r'^settings/email/preview$', event.MailSettingsPreview.as_view(), name='event.settings.mail.preview'),
|
||||
url(r'^settings/email/layoutpreview$', event.MailSettingsRendererPreview.as_view(),
|
||||
name='event.settings.mail.preview.layout'),
|
||||
url(r'^settings/cancel', event.CancelSettings.as_view(), name='event.settings.cancel'),
|
||||
url(r'^settings/invoice$', event.InvoiceSettings.as_view(), name='event.settings.invoice'),
|
||||
url(r'^settings/invoice/preview$', event.InvoicePreview.as_view(), name='event.settings.invoice.preview'),
|
||||
url(r'^settings/display', event.DisplaySettings.as_view(), name='event.settings.display'),
|
||||
|
||||
@@ -41,10 +41,10 @@ from pretix.base.signals import register_ticket_outputs
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.base.templatetags.rich_text import markdown_compile
|
||||
from pretix.control.forms.event import (
|
||||
CommentForm, DisplaySettingsForm, EventDeleteForm, EventMetaValueForm,
|
||||
EventSettingsForm, EventUpdateForm, InvoiceSettingsForm, MailSettingsForm,
|
||||
PaymentSettingsForm, ProviderForm, QuickSetupForm,
|
||||
QuickSetupProductFormSet, TaxRuleForm, TaxRuleLineFormSet,
|
||||
CancelSettingsForm, CommentForm, DisplaySettingsForm, EventDeleteForm,
|
||||
EventMetaValueForm, EventSettingsForm, EventUpdateForm,
|
||||
InvoiceSettingsForm, MailSettingsForm, PaymentSettingsForm, ProviderForm,
|
||||
QuickSetupForm, QuickSetupProductFormSet, TaxRuleForm, TaxRuleLineFormSet,
|
||||
TicketSettingsForm, WidgetCodeForm,
|
||||
)
|
||||
from pretix.control.permissions import EventPermissionRequiredMixin
|
||||
@@ -407,6 +407,43 @@ class InvoiceSettings(EventSettingsViewMixin, EventSettingsFormView):
|
||||
})
|
||||
|
||||
|
||||
class CancelSettings(EventSettingsViewMixin, EventSettingsFormView):
|
||||
model = Event
|
||||
form_class = CancelSettingsForm
|
||||
template_name = 'pretixcontrol/event/cancel.html'
|
||||
permission = 'can_change_event_settings'
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse('control:event.settings.cancel', kwargs={
|
||||
'organizer': self.request.event.organizer.slug,
|
||||
'event': self.request.event.slug
|
||||
})
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data()
|
||||
ctx['gets_notification'] = self.request.user.notifications_send and (
|
||||
(
|
||||
self.request.user.notification_settings.filter(
|
||||
event=self.request.event,
|
||||
action_type='pretix.event.order.refund.requested',
|
||||
enabled=True
|
||||
).exists()
|
||||
) or (
|
||||
self.request.user.notification_settings.filter(
|
||||
event__isnull=True,
|
||||
action_type='pretix.event.order.refund.requested',
|
||||
enabled=True
|
||||
).exists() and not
|
||||
self.request.user.notification_settings.filter(
|
||||
event=self.request.event,
|
||||
action_type='pretix.event.order.refund.requested',
|
||||
enabled=False
|
||||
).exists()
|
||||
)
|
||||
)
|
||||
return ctx
|
||||
|
||||
|
||||
class InvoicePreview(EventPermissionRequiredMixin, View):
|
||||
permission = 'can_change_event_settings'
|
||||
|
||||
|
||||
@@ -583,51 +583,15 @@ class OrderRefundView(OrderView):
|
||||
)
|
||||
|
||||
def choose_form(self):
|
||||
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
|
||||
payments = list(self.order.payments.filter(state=OrderPayment.PAYMENT_STATE_CONFIRMED))
|
||||
if self.start_form.cleaned_data.get('mode') == 'full':
|
||||
to_refund = full_refund = self.order.payment_refund_sum
|
||||
full_refund = self.order.payment_refund_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
|
||||
full_refund = self.start_form.cleaned_data.get('partial_amount')
|
||||
proposals = self.order.propose_auto_refunds(full_refund, payments=payments)
|
||||
to_refund = full_refund - sum(proposals.values())
|
||||
for p in payments:
|
||||
p.propose_refund = proposals.get(p, 0)
|
||||
|
||||
if 'perform' in self.request.POST:
|
||||
refund_selected = Decimal('0.00')
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
{% for i in invoices %}
|
||||
<li>
|
||||
<a href="{% eventurl event "presale:event.invoice.download" invoice=i.pk secret=order.secret order=order.code %}">
|
||||
{% if i.is_cancellation %}{% trans "Cancellation" %}{% else %}{% trans "Invoice" %}{% endif %}
|
||||
{% if i.is_cancellation %}{% trans "Cancellation" context "invoice" %}{% else %}{% trans "Invoice" %}{% endif %}
|
||||
{{ i.number }}</a> ({{ i.date|date:"SHORT_DATE_FORMAT" }})
|
||||
</li>
|
||||
{% endfor %}
|
||||
@@ -245,16 +245,57 @@
|
||||
{% endif %}
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
{% if order.can_user_cancel %}
|
||||
<div class="row">
|
||||
<div class="col-md-12 text-right">
|
||||
<p>
|
||||
<a href="{% eventurl event 'presale:event.order.cancel' secret=order.secret order=order.code %}"
|
||||
{% if order.cancel_allowed %}
|
||||
<div class="panel panel-primary cart">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
{% trans "Cancellation" context "action" %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% if order.user_cancel_allowed %}
|
||||
{% if order.status == "p" and order.total != 0 %}
|
||||
{% if order.user_cancel_fee %}
|
||||
<p>
|
||||
{% blocktrans trimmed with fee=order.user_cancel_fee|money:request.event.currency %}
|
||||
You can cancel this order. In this case, a cancellation fee of <strong>{{ fee }}</strong>
|
||||
will be kept and you will receive a refund of the remainder to your original payment method.
|
||||
{% endblocktrans %}
|
||||
{% trans "This will invalidate all of your tickets." %}
|
||||
</p>
|
||||
{% else %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You can cancel this order and receive a full refund to your original payment method.
|
||||
{% endblocktrans %}
|
||||
{% trans "This will invalidate all of your tickets." %}
|
||||
</p>
|
||||
{% endif %}
|
||||
<a href="{% eventurl event 'presale:event.order.cancel' secret=order.secret order=order.code %}"
|
||||
class="btn btn-danger">
|
||||
<span class="fa fa-remove"></span>
|
||||
{% trans "Cancel order" %}
|
||||
</a>
|
||||
{% else %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You can cancel this order using the following button.
|
||||
{% endblocktrans %}
|
||||
{% trans "This will invalidate all of your tickets." %}
|
||||
</p>
|
||||
<a href="{% eventurl event 'presale:event.order.cancel' secret=order.secret order=order.code %}"
|
||||
class="btn btn-danger">
|
||||
<span class="fa fa-remove"></span>
|
||||
{% trans "Cancel order" %}
|
||||
</a>
|
||||
</p>
|
||||
<span class="fa fa-remove"></span>
|
||||
{% trans "Cancel order" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You can not cancel this order yourself. Please contact the event organizer for more information.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "pretixpresale/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load money %}
|
||||
{% load eventurl %}
|
||||
{% block title %}{% trans "Cancel order" %}{% endblock %}
|
||||
{% block content %}
|
||||
@@ -8,9 +9,29 @@
|
||||
Cancel order: {{ code }}
|
||||
{% endblocktrans %}
|
||||
</h2>
|
||||
<p>{% blocktrans trimmed %}
|
||||
Do you really want to cancel this order? You cannot revert this action.
|
||||
{% endblocktrans %}</p>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
Do you really want to cancel this order? You cannot revert this action.
|
||||
{% endblocktrans %}
|
||||
{% trans "This will invalidate all of your tickets." %}
|
||||
</p>
|
||||
{% if can_auto_refund %}
|
||||
<p>
|
||||
<strong>
|
||||
{% blocktrans trimmed with amount=refund_amount|money:request.event.currency %}
|
||||
The refund amount of {{ amount }} will automatically be sent back to your original payment method. Depending on the payment method,
|
||||
please allow for up to two weeks before this appears on your statement.
|
||||
{% endblocktrans %}
|
||||
</strong>
|
||||
</p>
|
||||
{% else %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed with amount=refund_amount|money:request.event.currency %}
|
||||
With to the payment method you used, the refund amount of {{ amount }} <strong>can not be sent back to you automatically</strong>. Instead, the
|
||||
event organizer will need to initiate the transfer manually. Please be patient as this might take a bit longer.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{% eventurl request.event "presale:event.order.cancel.do" secret=order.secret order=order.code %}" data-asynctask>
|
||||
{% csrf_token %}
|
||||
|
||||
@@ -566,7 +566,7 @@ class OrderCancel(EventViewMixin, OrderDetailMixin, TemplateView):
|
||||
self.kwargs = kwargs
|
||||
if not self.order:
|
||||
raise Http404(_('Unknown order code or not authorized to access this order.'))
|
||||
if not self.order.can_user_cancel:
|
||||
if not self.order.user_cancel_allowed:
|
||||
messages.error(request, _('You cannot cancel this order.'))
|
||||
return redirect(self.get_order_url())
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
@@ -577,6 +577,10 @@ class OrderCancel(EventViewMixin, OrderDetailMixin, TemplateView):
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['order'] = self.order
|
||||
refund_amount = self.order.total - self.order.user_cancel_fee
|
||||
proposals = self.order.propose_auto_refunds(refund_amount)
|
||||
ctx['refund_amount'] = refund_amount
|
||||
ctx['can_auto_refund'] = sum(proposals.values()) == refund_amount
|
||||
return ctx
|
||||
|
||||
|
||||
@@ -594,10 +598,13 @@ class OrderCancelDo(EventViewMixin, OrderDetailMixin, AsyncAction, View):
|
||||
def post(self, request, *args, **kwargs):
|
||||
if not self.order:
|
||||
raise Http404(_('Unknown order code or not authorized to access this order.'))
|
||||
if not self.order.can_user_cancel:
|
||||
if not self.order.user_cancel_allowed:
|
||||
messages.error(request, _('You cannot cancel this order.'))
|
||||
return redirect(self.get_order_url())
|
||||
return self.do(self.order.pk)
|
||||
fee = None
|
||||
if self.order.status == Order.STATUS_PAID and self.order.total != Decimal('0.00'):
|
||||
fee = self.order.user_cancel_fee
|
||||
return self.do(self.order.pk, cancellation_fee=fee, try_auto_refund=True)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
|
||||
Reference in New Issue
Block a user