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:
Raphael Michel
2019-01-18 17:24:42 +01:00
committed by GitHub
parent 80b5750756
commit 06eddb2c6d
24 changed files with 857 additions and 95 deletions

View 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)
]

View File

@@ -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

View File

@@ -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 (

View File

@@ -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,
)

View File

@@ -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):

View File

@@ -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