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,6 +7,7 @@ from django.utils.translation import ugettext_lazy
from django_countries.fields import Country
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.relations import SlugRelatedField
from rest_framework.reverse import reverse
from pretix.api.serializers.i18n import I18nAwareModelSerializer
@@ -14,7 +15,9 @@ from pretix.base.models import (
Checkin, Invoice, InvoiceAddress, InvoiceLine, Order, OrderPosition,
Question, QuestionAnswer,
)
from pretix.base.models.orders import CartPosition, OrderFee
from pretix.base.models.orders import (
CartPosition, OrderFee, OrderPayment, OrderRefund,
)
from pretix.base.pdf import get_variables
from pretix.base.signals import register_ticket_outputs
@@ -156,23 +159,61 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
self.fields.pop('pdf_data')
class OrderPaymentTypeField(serializers.Field):
# TODO: Remove after pretix 2.2
def to_representation(self, instance: Order):
t = None
for p in instance.payments.all():
t = p.provider
return t
class OrderPaymentDateField(serializers.DateField):
# TODO: Remove after pretix 2.2
def to_representation(self, instance: Order):
t = None
for p in instance.payments.all():
t = p.payment_date or t
if t:
return super().to_representation(t.date())
class OrderFeeSerializer(I18nAwareModelSerializer):
class Meta:
model = OrderFee
fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule')
class OrderPaymentSerializer(I18nAwareModelSerializer):
class Meta:
model = OrderPayment
fields = ('local_id', 'state', 'amount', 'created', 'payment_date', 'provider')
class OrderRefundSerializer(I18nAwareModelSerializer):
payment = SlugRelatedField(slug_field='local_id', read_only=True)
class Meta:
model = OrderRefund
fields = ('local_id', 'state', 'source', 'amount', 'payment', 'created', 'execution_date', 'provider')
class OrderSerializer(I18nAwareModelSerializer):
invoice_address = InvoiceAddressSerializer()
positions = OrderPositionSerializer(many=True)
fees = OrderFeeSerializer(many=True)
downloads = OrderDownloadsField(source='*')
payments = OrderPaymentSerializer(many=True)
refunds = OrderRefundSerializer(many=True)
payment_date = OrderPaymentDateField(source='*')
payment_provider = OrderPaymentTypeField(source='*')
class Meta:
model = Order
fields = ('code', 'status', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads',
'checkin_attention', 'last_modified')
'checkin_attention', 'last_modified', 'payments', 'refunds')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -410,6 +451,9 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
def create(self, validated_data):
fees_data = validated_data.pop('fees') if 'fees' in validated_data else []
positions_data = validated_data.pop('positions') if 'positions' in validated_data else []
payment_provider = validated_data.pop('payment_provider')
payment_info = validated_data.pop('payment_info', '{}')
if 'invoice_address' in validated_data:
ia = InvoiceAddress(**validated_data.pop('invoice_address'))
else:
@@ -467,14 +511,32 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
order.set_expires(subevents=[p.get('subevent') for p in positions_data])
order.total = sum([p['price'] for p in positions_data]) + sum([f['value'] for f in fees_data], Decimal('0.00'))
order.meta_info = "{}"
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID:
order.payment_provider = 'free'
order.status = Order.STATUS_PAID
elif order.payment_provider == "free" and order.total != Decimal('0.00'):
raise ValidationError('You cannot use the "free" payment provider for non-free orders.')
if validated_data.get('status') == Order.STATUS_PAID:
order.payment_date = now()
order.save()
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID:
order.status = Order.STATUS_PAID
order.save()
order.payments.create(
amount=order.total, provider='free', state=OrderPayment.PAYMENT_STATE_CONFIRMED
)
elif payment_provider == "free" and order.total != Decimal('0.00'):
raise ValidationError('You cannot use the "free" payment provider for non-free orders.')
elif validated_data.get('status') == Order.STATUS_PAID:
order.payments.create(
amount=order.total,
provider=payment_provider,
info=payment_info,
payment_date=now(),
state=OrderPayment.PAYMENT_STATE_CONFIRMED
)
elif payment_provider:
order.payments.create(
amount=order.total,
provider=payment_provider,
info=payment_info,
state=OrderPayment.PAYMENT_STATE_CREATED
)
if ia:
ia.order = order
ia.save()
@@ -522,3 +584,27 @@ class InvoiceSerializer(I18nAwareModelSerializer):
'introductory_text', 'additional_text', 'payment_provider_text', 'footer_text', 'lines',
'foreign_currency_display', 'foreign_currency_rate', 'foreign_currency_rate_date',
'internal_reference')
class OrderRefundCreateSerializer(I18nAwareModelSerializer):
payment = serializers.IntegerField(required=False, allow_null=True)
provider = serializers.CharField(required=True, allow_null=False, allow_blank=False)
info = CompatibleJSONField(required=False)
class Meta:
model = OrderRefund
fields = ('state', 'source', 'amount', 'payment', 'execution_date', 'provider', 'info')
def create(self, validated_data):
pid = validated_data.pop('payment', None)
if pid:
try:
p = self.context['order'].payments.get(local_id=pid)
except OrderPayment.DoesNotExist:
raise ValidationError('Unknown payment ID.')
else:
p = None
order = OrderRefund(order=self.context['order'], payment=p, **validated_data)
order.save()
return order

View File

@@ -42,6 +42,10 @@ item_router = routers.DefaultRouter()
item_router.register(r'variations', item.ItemVariationViewSet)
item_router.register(r'addons', item.ItemAddOnViewSet)
order_router = routers.DefaultRouter()
order_router.register(r'payments', order.PaymentViewSet)
order_router.register(r'refunds', order.RefundViewSet)
# Force import of all plugins to give them a chance to register URLs with the router
for app in apps.get_app_configs():
if hasattr(app, 'PretixPluginMeta'):
@@ -57,6 +61,7 @@ urlpatterns = [
include(question_router.urls)),
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/checkinlists/(?P<list>[^/]+)/',
include(checkinlist_router.urls)),
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/orders/(?P<order>[^/]+)/', include(order_router.urls)),
url(r"^oauth/authorize$", oauth.AuthorizationView.as_view(), name="authorize"),
url(r"^oauth/token$", oauth.TokenView.as_view(), name="token"),
url(r"^oauth/revoke_token$", oauth.RevokeTokenView.as_view(), name="revoke-token"),

View File

@@ -6,6 +6,7 @@ from django.db import transaction
from django.db.models import Q
from django.db.models.functions import Concat
from django.http import FileResponse
from django.shortcuts import get_object_or_404
from django.utils.timezone import make_aware, now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from rest_framework import serializers, status, viewsets
@@ -19,12 +20,15 @@ from rest_framework.response import Response
from pretix.api.models import OAuthAccessToken
from pretix.api.serializers.order import (
InvoiceSerializer, OrderCreateSerializer, OrderPositionSerializer,
OrderSerializer,
InvoiceSerializer, OrderCreateSerializer, OrderPaymentSerializer,
OrderPositionSerializer, OrderRefundCreateSerializer,
OrderRefundSerializer, OrderSerializer,
)
from pretix.base.models import (
Invoice, Order, OrderPosition, Quota, TeamAPIToken,
Invoice, Order, OrderPayment, OrderPosition, OrderRefund, Quota,
TeamAPIToken,
)
from pretix.base.payment import PaymentException
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
regenerate_invoice,
@@ -32,7 +36,7 @@ from pretix.base.services.invoices import (
from pretix.base.services.mail import SendMailException
from pretix.base.services.orders import (
OrderError, cancel_order, extend_order, mark_order_expired,
mark_order_paid, mark_order_refunded,
mark_order_refunded,
)
from pretix.base.services.tickets import (
get_cachedticket_for_order, get_cachedticket_for_position,
@@ -70,7 +74,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
def get_queryset(self):
return self.request.event.orders.prefetch_related(
'positions', 'positions__checkins', 'positions__item', 'positions__answers', 'positions__answers__options',
'positions__answers__question', 'fees'
'positions__answers__question', 'fees', 'payments', 'refunds', 'refunds__payment'
).select_related(
'invoice_address'
)
@@ -122,14 +126,33 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
order = self.get_object()
if order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED):
ps = order.pending_sum
try:
mark_order_paid(
order, manual=True,
user=request.user if request.user.is_authenticated else None,
auth=request.auth,
p = order.payments.get(
state__in=(OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED),
provider='manual',
amount=ps
)
except OrderPayment.DoesNotExist:
order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_PENDING,
OrderPayment.PAYMENT_STATE_CREATED)) \
.update(state=OrderPayment.PAYMENT_STATE_CANCELED)
p = order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED,
provider='manual',
amount=ps,
fee=None
)
try:
p.confirm(auth=self.request.auth,
user=self.request.user if request.user.is_authenticated else None,
count_waitinglist=False)
except Quota.QuotaExceededException as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
except PaymentException as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
except SendMailException:
pass
@@ -170,7 +193,6 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
)
order.status = Order.STATUS_PENDING
order.payment_manual = True
order.save()
order.log_action(
'pretix.event.order.unpaid',
@@ -366,6 +388,205 @@ class OrderPositionViewSet(viewsets.ReadOnlyModelViewSet):
return resp
class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = OrderPaymentSerializer
queryset = OrderPayment.objects.none()
permission = 'can_view_orders'
write_permission = 'can_change_orders'
lookup_field = 'local_id'
def get_queryset(self):
order = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event)
return order.payments.all()
@detail_route(methods=['POST'])
def confirm(self, request, **kwargs):
payment = self.get_object()
force = request.data.get('force', False)
if payment.state not in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED):
return Response({'detail': 'Invalid state of payment'}, status=status.HTTP_400_BAD_REQUEST)
try:
payment.confirm(user=self.request.user if self.request.user.is_authenticated else None,
auth=self.request.auth,
count_waitinglist=False,
force=force)
except Quota.QuotaExceededException as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
except PaymentException as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
except SendMailException:
pass
return self.retrieve(request, [], **kwargs)
@detail_route(methods=['POST'])
def refund(self, request, **kwargs):
payment = self.get_object()
amount = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
request.data.get('amount', str(payment.amount))
)
mark_refunded = request.data.get('mark_refunded', False)
if payment.state != OrderPayment.PAYMENT_STATE_CONFIRMED:
return Response({'detail': 'Invalid state of payment.'}, status=status.HTTP_400_BAD_REQUEST)
full_refund_possible = payment.payment_provider.payment_refund_supported(payment)
partial_refund_possible = payment.payment_provider.payment_partial_refund_supported(payment)
available_amount = payment.amount - payment.refunded_amount
if amount <= 0:
return Response({'amount': ['Invalid refund amount.']}, status=status.HTTP_400_BAD_REQUEST)
if amount > available_amount:
return Response(
{'amount': ['Invalid refund amount, only {} are available to refund.'.format(available_amount)]},
status=status.HTTP_400_BAD_REQUEST)
if amount != payment.amount and not partial_refund_possible:
return Response({'amount': ['Partial refund not available for this payment method.']},
status=status.HTTP_400_BAD_REQUEST)
if amount == payment.amount and not full_refund_possible:
return Response({'amount': ['Full refund not available for this payment method.']},
status=status.HTTP_400_BAD_REQUEST)
r = payment.order.refunds.create(
payment=payment,
source=OrderRefund.REFUND_SOURCE_ADMIN,
state=OrderRefund.REFUND_STATE_CREATED,
amount=amount,
provider=payment.provider
)
try:
r.payment_provider.execute_refund(r)
except PaymentException as e:
r.state = OrderRefund.REFUND_STATE_FAILED
r.save()
return Response({'detail': 'External error: {}'.format(str(e))},
status=status.HTTP_400_BAD_REQUEST)
else:
payment.order.log_action('pretix.event.order.refund.created', {
'local_id': r.local_id,
'provider': r.provider,
}, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
if payment.order.pending_sum > 0:
if mark_refunded:
mark_order_refunded(payment.order,
user=self.request.user if self.request.user.is_authenticated else None,
auth=self.request.auth)
else:
payment.order.status = Order.STATUS_PENDING
payment.order.set_expires(
now(),
payment.order.event.subevents.filter(
id__in=payment.order.positions.values_list('subevent_id', flat=True))
)
payment.order.save()
return Response(OrderRefundSerializer(r).data, status=status.HTTP_200_OK)
@detail_route(methods=['POST'])
def cancel(self, request, **kwargs):
payment = self.get_object()
if payment.state not in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED):
return Response({'detail': 'Invalid state of payment'}, status=status.HTTP_400_BAD_REQUEST)
with transaction.atomic():
payment.state = OrderPayment.PAYMENT_STATE_CANCELED
payment.save()
payment.order.log_action('pretix.event.order.payment.canceled', {
'local_id': payment.local_id,
'provider': payment.provider,
}, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
return self.retrieve(request, [], **kwargs)
class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = OrderRefundSerializer
queryset = OrderRefund.objects.none()
permission = 'can_view_orders'
write_permission = 'can_change_orders'
lookup_field = 'local_id'
def get_queryset(self):
order = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event)
return order.refunds.all()
@detail_route(methods=['POST'])
def cancel(self, request, **kwargs):
refund = self.get_object()
if refund.state not in (OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT,
OrderRefund.REFUND_STATE_EXTERNAL):
return Response({'detail': 'Invalid state of refund'}, status=status.HTTP_400_BAD_REQUEST)
with transaction.atomic():
refund.state = OrderRefund.REFUND_STATE_CANCELED
refund.save()
refund.order.log_action('pretix.event.order.refund.canceled', {
'local_id': refund.local_id,
'provider': refund.provider,
}, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
return self.retrieve(request, [], **kwargs)
@detail_route(methods=['POST'])
def process(self, request, **kwargs):
refund = self.get_object()
if refund.state != OrderRefund.REFUND_STATE_EXTERNAL:
return Response({'detail': 'Invalid state of refund'}, status=status.HTTP_400_BAD_REQUEST)
refund.done(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
if request.data.get('mark_refunded', False):
mark_order_refunded(refund.order, user=self.request.user if self.request.user.is_authenticated else None,
auth=self.request.auth)
else:
refund.order.status = Order.STATUS_PENDING
refund.order.set_expires(
now(),
refund.order.event.subevents.filter(
id__in=refund.order.positions.values_list('subevent_id', flat=True))
)
refund.order.save()
return self.retrieve(request, [], **kwargs)
@detail_route(methods=['POST'])
def done(self, request, **kwargs):
refund = self.get_object()
if refund.state not in (OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT):
return Response({'detail': 'Invalid state of refund'}, status=status.HTTP_400_BAD_REQUEST)
refund.done(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
return self.retrieve(request, [], **kwargs)
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['order'] = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event)
return ctx
def create(self, request, *args, **kwargs):
serializer = OrderRefundCreateSerializer(data=request.data, context=self.get_serializer_context())
serializer.is_valid(raise_exception=True)
with transaction.atomic():
self.perform_create(serializer)
r = serializer.instance
serializer = OrderRefundSerializer(r, context=serializer.context)
r.order.log_action(
'pretix.event.order.refund.created', {
'local_id': r.local_id,
'provider': r.provider,
},
user=request.user if request.user.is_authenticated else None,
auth=request.auth
)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def perform_create(self, serializer):
serializer.save()
class InvoiceFilter(FilterSet):
refers = django_filters.CharFilter(method='refers_qs')
number = django_filters.CharFilter(method='nr_qs')

View File

@@ -5,9 +5,12 @@ from zipfile import ZipFile
import dateutil.parser
from django import forms
from django.db.models import Exists, OuterRef, Q
from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import OrderPayment
from ..exporter import BaseExporter
from ..services.invoices import invoice_pdf_task
from ..signals import register_data_exporters
@@ -21,7 +24,14 @@ class InvoiceExporter(BaseExporter):
qs = self.event.invoices.filter(shredded=False)
if form_data.get('payment_provider'):
qs = qs.filter(order__payment_provider=form_data.get('payment_provider'))
qs = qs.annotate(
has_payment_with_provider=Exists(
OrderPayment.objects.filter(
Q(order=OuterRef('pk')) & Q(provider=form_data.get('payment_provider'))
)
)
)
qs = qs.filter(has_payment_with_provider=1)
if form_data.get('date_from'):
date_value = form_data.get('date_from')
@@ -84,10 +94,10 @@ class InvoiceExporter(BaseExporter):
(k, v.verbose_name) for k, v in self.event.get_payment_providers().items()
],
required=False,
help_text=_('Only include invoices for orders that are currently set to this payment provider. '
'Note that this might include some invoices of other payment providers or misses '
'some invoices if the payment provider of an order has been changed and a new invoice '
'has been generated.')
help_text=_('Only include invoices for orders that have at least one payment attempt '
'with this payment provider. '
'Note that this might include some invoices of orders which in the end have been '
'fully or partially paid with a different provider.')
)),
]
)

View File

@@ -5,13 +5,13 @@ from decimal import Decimal
import pytz
from defusedcsv import csv
from django import forms
from django.db.models import Sum
from django.db.models import DateTimeField, Max, OuterRef, Subquery, Sum
from django.dispatch import receiver
from django.utils.formats import localize
from django.utils.translation import ugettext as _, ugettext_lazy
from pretix.base.models import InvoiceAddress, Order, OrderPosition
from pretix.base.models.orders import OrderFee
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
from ..exporter import BaseExporter
from ..signals import register_data_exporters
@@ -55,7 +55,19 @@ class OrderListExporter(BaseExporter):
tz = pytz.timezone(self.event.settings.timezone)
writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",")
qs = self.event.orders.all().select_related('invoice_address').prefetch_related('invoices')
p_date = OrderPayment.objects.filter(
order=OuterRef('pk'),
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
payment_date__isnull=False
).order_by().values('order').annotate(
m=Max('payment_date')
).values(
'm'
)
qs = self.event.orders.annotate(
payment_date=Subquery(p_date, output_field=DateTimeField())
).select_related('invoice_address').prefetch_related('invoices')
if form_data['paid_only']:
qs = qs.filter(status=Order.STATUS_PAID)
tax_rates = self._get_all_tax_rates(qs)
@@ -63,7 +75,7 @@ class OrderListExporter(BaseExporter):
headers = [
_('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'),
_('Company'), _('Name'), _('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
_('Payment date'), _('Payment type'), _('Fees'), _('Order locale')
_('Date of last payment'), _('Fees'), _('Order locale')
]
for tr in tax_rates:
@@ -77,11 +89,6 @@ class OrderListExporter(BaseExporter):
writer.writerow(headers)
provider_names = {
k: v.verbose_name
for k, v in self.event.get_payment_providers().items()
}
full_fee_sum_cache = {
o['order__id']: o['grosssum'] for o in
OrderFee.objects.values('tax_rate', 'order__id').order_by().annotate(grosssum=Sum('value'))
@@ -114,7 +121,8 @@ class OrderListExporter(BaseExporter):
order.invoice_address.street,
order.invoice_address.zipcode,
order.invoice_address.city,
order.invoice_address.country if order.invoice_address.country else order.invoice_address.country_old,
order.invoice_address.country if order.invoice_address.country else
order.invoice_address.country_old,
order.invoice_address.vat_id,
]
except InvoiceAddress.DoesNotExist:
@@ -122,14 +130,14 @@ class OrderListExporter(BaseExporter):
row += [
order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '',
provider_names.get(order.payment_provider, order.payment_provider),
localize(full_fee_sum_cache.get(order.id) or Decimal('0.00')),
order.locale,
]
for tr in tax_rates:
taxrate_values = sum_cache.get((order.id, tr), {'grosssum': Decimal('0.00'), 'taxsum': Decimal('0.00')})
fee_taxrate_values = fee_sum_cache.get((order.id, tr), {'grosssum': Decimal('0.00'), 'taxsum': Decimal('0.00')})
fee_taxrate_values = fee_sum_cache.get((order.id, tr),
{'grosssum': Decimal('0.00'), 'taxsum': Decimal('0.00')})
row += [
localize(taxrate_values['grosssum'] + fee_taxrate_values['grosssum']),
@@ -144,6 +152,77 @@ class OrderListExporter(BaseExporter):
return '{}_orders.csv'.format(self.event.slug), 'text/csv', output.getvalue().encode("utf-8")
class PaymentListExporter(BaseExporter):
identifier = 'paymentlistcsv'
verbose_name = ugettext_lazy('List of payments and refunds (CSV)')
@property
def export_form_fields(self):
return OrderedDict(
[
('successful_only',
forms.BooleanField(
label=_('Only successful payments'),
initial=True,
required=False
)),
]
)
def render(self, form_data: dict):
output = io.StringIO()
tz = pytz.timezone(self.event.settings.timezone)
writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",")
provider_names = {
k: v.verbose_name
for k, v in self.event.get_payment_providers().items()
}
payments = OrderPayment.objects.filter(
order__event=self.event,
).order_by('created')
refunds = OrderRefund.objects.filter(
order__event=self.event
).order_by('created')
if form_data['successful_only']:
payments = payments.filter(
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
)
refunds = refunds.filter(
state=OrderRefund.REFUND_STATE_DONE,
)
objs = sorted(list(payments) + list(refunds), key=lambda o: o.created)
headers = [
_('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
_('Amount'), _('Payment method')
]
writer.writerow(headers)
for obj in objs:
if isinstance(obj, OrderPayment) and obj.payment_date:
d2 = obj.payment_date.astimezone(tz).date().strftime('%Y-%m-%d')
elif isinstance(obj, OrderRefund) and obj.execution_date:
d2 = obj.execution_date.astimezone(tz).date().strftime('%Y-%m-%d')
else:
d2 = ''
row = [
obj.order.code,
obj.full_id,
obj.created.astimezone(tz).date().strftime('%Y-%m-%d'),
d2,
obj.get_state_display(),
localize(obj.amount * (-1 if isinstance(obj, OrderRefund) else 1)),
provider_names.get(obj.provider, obj.provider)
]
writer.writerow(row)
return '{}_payments.csv'.format(self.event.slug), 'text/csv', output.getvalue().encode("utf-8")
class QuotaListExporter(BaseExporter):
identifier = 'quotalistcsv'
verbose_name = ugettext_lazy('Quota availabilities (CSV)')
@@ -180,6 +259,11 @@ def register_orderlist_exporter(sender, **kwargs):
return OrderListExporter
@receiver(register_data_exporters, dispatch_uid="exporter_paymentlist")
def register_paymentlist_exporter(sender, **kwargs):
return PaymentListExporter
@receiver(register_data_exporters, dispatch_uid="exporter_quotalist")
def register_quotalist_exporter(sender, **kwargs):
return QuotaListExporter

View File

@@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.13 on 2018-07-22 08:01
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0095_auto_20180604_1129'),
]
operations = [
migrations.CreateModel(
name='OrderPayment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('local_id', models.PositiveIntegerField()),
('state', models.CharField(choices=[('created', 'created'), ('pending', 'pending'), ('confirmed', 'confirmed'), ('canceled', 'canceled'), ('failed', 'failed'), ('refunded', 'refunded')], max_length=190)),
('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Amount')),
('created', models.DateTimeField(auto_now_add=True)),
('payment_date', models.DateTimeField(blank=True, null=True)),
('provider', models.CharField(blank=True, max_length=255, null=True, verbose_name='Payment provider')),
('info', models.TextField(blank=True, null=True, verbose_name='Payment information')),
('migrated', models.BooleanField(default=False)),
],
options={
'ordering': ('local_id',),
},
),
migrations.CreateModel(
name='OrderRefund',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('local_id', models.PositiveIntegerField()),
('state', models.CharField(choices=[('external', 'started externally'), ('created', 'created'), ('transit', 'in transit'), ('done', 'done'), ('failed', 'failed'), ('canceled', 'canceled')], max_length=190)),
('source', models.CharField(choices=[('admin', 'Organizer'), ('buyer', 'Customer'), ('external', 'External')], max_length=190)),
('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Amount')),
('created', models.DateTimeField(auto_now_add=True)),
('execution_date', models.DateTimeField(blank=True, null=True)),
('provider', models.CharField(blank=True, max_length=255, null=True, verbose_name='Payment provider')),
('info', models.TextField(blank=True, null=True, verbose_name='Payment information')),
('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='refunds', to='pretixbase.Order', verbose_name='Order')),
('payment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='refunds', to='pretixbase.OrderPayment')),
],
options={
'ordering': ('local_id',),
},
),
migrations.AlterModelOptions(
name='quota',
options={'ordering': ('name',), 'verbose_name': 'Quota', 'verbose_name_plural': 'Quotas'},
),
migrations.AlterField(
model_name='orderfee',
name='fee_type',
field=models.CharField(choices=[('payment', 'Payment fee'), ('shipping', 'Shipping fee'), ('service', 'Service fee'), ('other', 'Other fees'), ('giftcard', 'Gift card')], max_length=100),
),
migrations.AlterField(
model_name='team',
name='can_change_organizer_settings',
field=models.BooleanField(default=False, help_text='Someone with this setting can get access to most data of all of your events, i.e. via privacy reports, so be careful who you add to this team!', verbose_name='Can change organizer settings'),
),
migrations.AlterField(
model_name='user',
name='require_2fa',
field=models.BooleanField(default=False, verbose_name='Two-factor authentication is required to log in'),
),
migrations.AddField(
model_name='orderpayment',
name='fee',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='pretixbase.OrderFee'),
),
migrations.AddField(
model_name='orderpayment',
name='order',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='pretixbase.Order', verbose_name='Order'),
),
]

View File

@@ -0,0 +1,118 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.13 on 2018-07-22 08:04
from __future__ import unicode_literals
from django.db import migrations
def create_payments(apps, schema_editor):
Order = apps.get_model('pretixbase', 'Order') # noqa
OrderPayment = apps.get_model('pretixbase', 'OrderPayment') # noqa
OrderRefund = apps.get_model('pretixbase', 'OrderRefund') # noqa
payments = []
refunds = []
for o in Order.objects.filter(payments__isnull=True).iterator():
if o.status == 'n' or o.status == 'e':
payments.append(OrderPayment(
local_id=1,
state='created',
amount=o.total,
order=o,
provider=o.payment_provider,
info=o.payment_info,
migrated=True,
fee=o.fees.filter(fee_type="payment", internal_type=o.payment_provider).first(),
))
pass
elif o.status == 'p':
payments.append(OrderPayment(
local_id=1,
state='confirmed',
amount=o.total,
order=o,
provider=o.payment_provider,
payment_date=o.payment_date,
info=o.payment_info,
migrated=True,
fee=o.fees.filter(fee_type="payment", internal_type=o.payment_provider).first(),
))
elif o.status == 'r':
p = OrderPayment.objects.create(
local_id=1,
state='refunded',
amount=o.total,
order=o,
provider=o.payment_provider,
payment_date=o.payment_date,
info=o.payment_info,
migrated=True,
fee=o.fees.filter(fee_type="payment", internal_type=o.payment_provider).first(),
)
refunds.append(OrderRefund(
local_id=1,
state='done',
amount=o.total,
order=o,
provider=o.payment_provider,
info=o.payment_info,
source='admin',
payment=p
))
elif o.status == 'c':
payments.append(OrderPayment(
local_id=1,
state='canceled',
amount=o.total,
order=o,
provider=o.payment_provider,
payment_date=o.payment_date,
info=o.payment_info,
migrated=True,
fee=o.fees.filter(fee_type="payment", internal_type=o.payment_provider).first(),
))
if len(payments) > 500:
OrderPayment.objects.bulk_create(payments)
payments.clear()
if len(refunds) > 500:
OrderRefund.objects.bulk_create(refunds)
refunds.clear()
if len(payments) > 0:
OrderPayment.objects.bulk_create(payments)
if len(refunds) > 0:
OrderRefund.objects.bulk_create(refunds)
def notifications(apps, schema_editor):
NotificationSetting = apps.get_model('pretixbase', 'NotificationSetting')
for n in NotificationSetting.objects.filter(action_type='pretix.event.action_required'):
n.pk = None
n.action_type = 'pretix.event.order.refund.created.externally'
n.save()
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0096_auto_20180722_0801'),
]
operations = [
migrations.RunPython(create_payments, migrations.RunPython.noop),
migrations.RunPython(notifications, migrations.RunPython.noop),
migrations.RemoveField(
model_name='order',
name='payment_date',
),
migrations.RemoveField(
model_name='order',
name='payment_info',
),
migrations.RemoveField(
model_name='order',
name='payment_manual',
),
migrations.RemoveField(
model_name='order',
name='payment_provider',
),
]

View File

@@ -15,9 +15,9 @@ from .log import LogEntry
from .notifications import NotificationSetting
from .orders import (
AbstractPosition, CachedCombinedTicket, CachedTicket, CartPosition,
InvoiceAddress, Order, OrderPosition, QuestionAnswer,
cachedcombinedticket_name, cachedticket_name, generate_position_secret,
generate_secret,
InvoiceAddress, Order, OrderFee, OrderPayment, OrderPosition, OrderRefund,
QuestionAnswer, cachedcombinedticket_name, cachedticket_name,
generate_position_secret, generate_secret,
)
from .organizer import (
Organizer, Organizer_SettingsStore, Team, TeamAPIToken, TeamInvite,

View File

@@ -561,7 +561,7 @@ class Event(EventMixin, LoggedModel):
def has_payment_provider(self):
result = False
for provider in self.get_payment_providers().values():
if provider.is_enabled and provider.identifier not in ('free', 'boxoffice'):
if provider.is_enabled and provider.identifier not in ('free', 'boxoffice', 'offsetting'):
result = True
break
return result

View File

@@ -52,7 +52,7 @@ class LogEntry(models.Model):
all = models.Manager()
class Meta:
ordering = ('-datetime',)
ordering = ('-datetime', '-id')
def display(self):
from ..signals import logentry_display

View File

@@ -1,5 +1,6 @@
import copy
import json
import logging
import os
import string
from datetime import datetime, time, timedelta
@@ -9,8 +10,11 @@ from typing import Any, Dict, List, Union
import dateutil
import pytz
from django.conf import settings
from django.db import models
from django.db.models import F, Sum
from django.db import models, transaction
from django.db.models import (
Case, Exists, F, Max, OuterRef, Q, Subquery, Sum, Value, When,
)
from django.db.models.functions import Coalesce
from django.db.models.signals import post_delete
from django.dispatch import receiver
from django.urls import reverse
@@ -31,6 +35,8 @@ from .base import LoggedModel
from .event import Event, SubEvent
from .items import Item, ItemVariation, Question, QuestionOption, Quota
logger = logging.getLogger(__name__)
def generate_secret():
return get_random_string(length=16, allowed_chars=string.ascii_lowercase + string.digits)
@@ -76,12 +82,6 @@ class Order(LoggedModel):
:type datetime: datetime
:param expires: The date until this order has to be paid to guarantee the fulfillment
:type expires: datetime
:param payment_date: The date of the payment completion (null if not yet paid)
:type payment_date: datetime
:param payment_provider: The payment provider selected by the user
:type payment_provider: str
:param payment_info: Arbitrary information stored by the payment provider
:type payment_info: str
:param total: The total amount of the order, including the payment fee
:type total: decimal.Decimal
:param comment: An internal comment that will only be visible to staff, and never displayed to the user
@@ -136,23 +136,6 @@ class Order(LoggedModel):
expires = models.DateTimeField(
verbose_name=_("Expiration date")
)
payment_date = models.DateTimeField(
verbose_name=_("Payment date"),
null=True, blank=True
)
payment_provider = models.CharField(
null=True, blank=True,
max_length=255,
verbose_name=_("Payment provider")
)
payment_info = models.TextField(
verbose_name=_("Payment information"),
null=True, blank=True
)
payment_manual = models.BooleanField(
verbose_name=_("Payment state was manually modified"),
default=False
)
total = models.DecimalField(
decimal_places=2, max_digits=10,
verbose_name=_("Total amount")
@@ -199,6 +182,68 @@ class Order(LoggedModel):
except TypeError:
return None
@property
def pending_sum(self):
total = self.total
if self.status in (Order.STATUS_REFUNDED, Order.STATUS_CANCELED):
total = 0
payment_sum = self.payments.filter(
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
refund_sum = self.refunds.filter(
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
OrderRefund.REFUND_STATE_CREATED)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
return total - payment_sum + refund_sum
@classmethod
def annotate_overpayments(cls, qs):
payment_sum = OrderPayment.objects.filter(
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
order=OuterRef('pk')
).order_by().values('order').annotate(s=Sum('amount')).values('s')
refund_sum = OrderRefund.objects.filter(
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
OrderRefund.REFUND_STATE_CREATED),
order=OuterRef('pk')
).order_by().values('order').annotate(s=Sum('amount')).values('s')
external_refund = OrderRefund.objects.filter(
state=OrderRefund.REFUND_STATE_EXTERNAL,
order=OuterRef('pk')
)
pending_refund = OrderRefund.objects.filter(
state__in=(OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT),
order=OuterRef('pk')
)
qs = qs.annotate(
payment_sum=Subquery(payment_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10)),
refund_sum=Subquery(refund_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10)),
has_external_refund=Exists(external_refund),
has_pending_refund=Exists(pending_refund),
).annotate(
pending_sum_t=F('total') - Coalesce(F('payment_sum'), 0) + Coalesce(F('refund_sum'), 0),
pending_sum_rc=-1 * F('payment_sum') + Coalesce(F('refund_sum'), 0),
).annotate(
is_overpaid=Case(
When(~Q(status__in=[Order.STATUS_REFUNDED, Order.STATUS_CANCELED]) & Q(pending_sum_t__lt=0),
then=Value('1')),
When(Q(status__in=[Order.STATUS_REFUNDED, Order.STATUS_CANCELED]) & Q(pending_sum_rc__lt=0),
then=Value('1')),
When(Q(status__in=[Order.STATUS_EXPIRED, Order.STATUS_PENDING]) & Q(pending_sum_t__lte=0),
then=Value('1')),
default=Value('0'),
output_field=models.IntegerField()
),
is_underpaid=Case(
When(Q(status=Order.STATUS_PAID) & Q(pending_sum_t__gt=0),
then=Value('1')),
default=Value('0'),
output_field=models.IntegerField()
)
)
return qs
@property
def full_code(self):
"""
@@ -711,10 +756,441 @@ class AbstractPosition(models.Model):
else self.variation.quotas.filter(subevent=self.subevent))
class OrderPayment(models.Model):
"""
Represents a payment or payment attempt for an order.
:param id: A globally unique ID for this payment
:type id:
:param local_id: An ID of this payment, counting from one for every order independently.
:type local_id: int
:param state: The state of the payment, one of ``created``, ``pending``, ``confirmed``, ``failed``,
``canceled``, or ``refunded``.
:type state: str
:param amount: The payment amount
:type amount: Decimal
:param order: The order that is paid
:type order: Order
:param created: The creation time of this record
:type created: datetime
:param payment_date: The completion time of this payment
:type payment_date: datetime
:param provider: The payment provider in use
:type provider: str
:param info: Provider-specific meta information (in JSON format)
:type info: str
:param fee: The ``OrderFee`` object used to track the fee for this order.
:type fee: pretix.base.models.OrderFee
"""
PAYMENT_STATE_CREATED = 'created'
PAYMENT_STATE_PENDING = 'pending'
PAYMENT_STATE_CONFIRMED = 'confirmed'
PAYMENT_STATE_FAILED = 'failed'
PAYMENT_STATE_CANCELED = 'canceled'
PAYMENT_STATE_REFUNDED = 'refunded'
PAYMENT_STATES = (
(PAYMENT_STATE_CREATED, pgettext_lazy('payment_state', 'created')),
(PAYMENT_STATE_PENDING, pgettext_lazy('payment_state', 'pending')),
(PAYMENT_STATE_CONFIRMED, pgettext_lazy('payment_state', 'confirmed')),
(PAYMENT_STATE_CANCELED, pgettext_lazy('payment_state', 'canceled')),
(PAYMENT_STATE_FAILED, pgettext_lazy('payment_state', 'failed')),
(PAYMENT_STATE_REFUNDED, pgettext_lazy('payment_state', 'refunded')),
)
local_id = models.PositiveIntegerField()
state = models.CharField(
max_length=190, choices=PAYMENT_STATES
)
amount = models.DecimalField(
decimal_places=2, max_digits=10,
verbose_name=_("Amount")
)
order = models.ForeignKey(
Order,
verbose_name=_("Order"),
related_name='payments',
on_delete=models.PROTECT
)
created = models.DateTimeField(
auto_now_add=True
)
payment_date = models.DateTimeField(
null=True, blank=True
)
provider = models.CharField(
null=True, blank=True,
max_length=255,
verbose_name=_("Payment provider")
)
info = models.TextField(
verbose_name=_("Payment information"),
null=True, blank=True
)
fee = models.ForeignKey(
'OrderFee',
null=True, blank=True, related_name='payments'
)
migrated = models.BooleanField(default=False)
class Meta:
ordering = ('local_id',)
@property
def info_data(self):
"""
This property allows convenient access to the data stored in the ``info``
attribute by automatically encoding and decoding the content as JSON.
"""
return json.loads(self.info) if self.info else {}
@info_data.setter
def info_data(self, d):
self.info = json.dumps(d)
@cached_property
def payment_provider(self):
"""
Cached access to an instance of the payment provider in use.
"""
return self.order.event.get_payment_providers().get(self.provider)
def confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text=''):
"""
Marks the payment as complete. If possible, this also marks the order as paid if no further
payment is required
:param count_waitinglist: Whether, when calculating quota, people on the waiting list should be taken into
consideration (default: ``True``).
:type count_waitinglist: boolean
:param force: Whether this payment should be marked as paid even if no remaining
quota is available (default: ``False``).
:type force: boolean
:param send_mail: Whether an email should be sent to the user about this event (default: ``True``).
:type send_mail: boolean
:param user: The user who performed the change
:param auth: The API auth token that performed the change
:param mail_text: Additional text to be included in the email
:type mail_text: str
:raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False``
"""
from pretix.base.signals import order_paid
from pretix.base.services.invoices import generate_invoice, invoice_qualified
from pretix.base.services.mail import SendMailException
from pretix.multidomain.urlreverse import build_absolute_uri
self.state = self.PAYMENT_STATE_CONFIRMED
self.payment_date = now()
self.save()
self.order.log_action('pretix.event.order.payment.confirmed', {
'local_id': self.local_id,
'provider': self.provider,
}, user=user, auth=auth)
if self.order.status == Order.STATUS_PAID:
return
payment_sum = self.order.payments.filter(
state__in=(self.PAYMENT_STATE_CONFIRMED, self.PAYMENT_STATE_REFUNDED)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
refund_sum = self.order.refunds.filter(
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
OrderRefund.REFUND_STATE_CREATED)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
if payment_sum - refund_sum < self.order.total:
return
with self.order.event.lock():
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist)
if not force and can_be_paid is not True:
raise Quota.QuotaExceededException(can_be_paid)
self.order.status = Order.STATUS_PAID
self.order.save()
self.order.log_action('pretix.event.order.paid', {
'provider': self.provider,
'info': self.info,
'date': self.payment_date,
'force': force
}, user=user, auth=auth)
order_paid.send(self.order.event, order=self.order)
invoice = None
if invoice_qualified(self.order):
invoices = self.order.invoices.filter(is_cancellation=False).count()
cancellations = self.order.invoices.filter(is_cancellation=True).count()
gen_invoice = (
(invoices == 0 and self.order.event.settings.get('invoice_generate') in ('True', 'paid')) or
0 < invoices <= cancellations
)
if gen_invoice:
invoice = generate_invoice(
self.order,
trigger_pdf=not send_mail or not self.order.event.settings.invoice_email_attachment
)
if send_mail:
with language(self.order.locale):
try:
invoice_name = self.order.invoice_address.name
invoice_company = self.order.invoice_address.company
except InvoiceAddress.DoesNotExist:
invoice_name = ""
invoice_company = ""
email_template = self.order.event.settings.mail_text_order_paid
email_context = {
'event': self.order.event.name,
'url': build_absolute_uri(self.order.event, 'presale:event.order', kwargs={
'order': self.order.code,
'secret': self.order.secret
}),
'downloads': self.order.event.settings.get('ticket_download', as_type=bool),
'invoice_name': invoice_name,
'invoice_company': invoice_company,
'payment_info': mail_text
}
email_subject = _('Payment received for your order: %(code)s') % {'code': self.order.code}
try:
self.order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_paid', user,
invoices=[invoice] if invoice and self.order.event.settings.invoice_email_attachment else []
)
except SendMailException:
logger.exception('Order paid email could not be sent')
@property
def refunded_amount(self):
"""
The sum of all refund amounts in ``done``, ``transit``, or ``created`` states associated
with this payment.
"""
return self.refunds.filter(
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
OrderRefund.REFUND_STATE_CREATED)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
@property
def full_id(self):
"""
The full human-readable ID of this payment, constructed by the order code and the ``local_id``
field with ``-P-`` in between.
:return:
"""
return '{}-P-{}'.format(self.order.code, self.local_id)
def save(self, *args, **kwargs):
if not self.local_id:
self.local_id = (self.order.payments.aggregate(m=Max('local_id'))['m'] or 0) + 1
super().save(*args, **kwargs)
def create_external_refund(self, amount=None, execution_date=None, info='{}'):
"""
This should be called to create an OrderRefund object when a refund has triggered
by an external source, e.g. when a credit card payment has been refunded by the
credit card provider.
:param amount: Amount to refund. If not given, the full payment amount will be used.
:type amount: Decimal
:param execution_date: Date of the refund. Defaults to the current time.
:type execution_date: datetime
:param info: Additional information, defaults to ``"{}"``.
:type info: str
:return: OrderRefund
"""
r = self.order.refunds.create(
state=OrderRefund.REFUND_STATE_EXTERNAL,
source=OrderRefund.REFUND_SOURCE_EXTERNAL,
amount=amount if amount is not None else self.amount,
order=self.order,
payment=self,
execution_date=execution_date or now(),
provider=self.provider,
info=info
)
self.order.log_action('pretix.event.order.refund.created.externally', {
'local_id': r.local_id,
'provider': r.provider,
})
return r
class OrderRefund(models.Model):
"""
Represents a refund or refund attempt for an order.
:param id: A globally unique ID for this refund
:type id:
:param local_id: An ID of this refund, counting from one for every order independently.
:type local_id: int
:param state: The state of the refund, one of ``created``, ``transit``, ``external``, ``canceled``,
``failed``, or ``done``.
:type state: str
:param source: How this refund was started, one of ``buyer``, ``admin``, or ``external``.
:param amount: The refund amount
:type amount: Decimal
:param order: The order that is refunded
:type order: Order
:param created: The creation time of this record
:type created: datetime
:param execution_date: The completion time of this refund
:type execution_date: datetime
:param provider: The payment provider in use
:type provider: str
:param info: Provider-specific meta information in JSON format
:type info: dict
"""
# REFUND_STATE_REQUESTED = 'requested'
# REFUND_STATE_APPROVED = 'approved'
REFUND_STATE_EXTERNAL = 'external'
REFUND_STATE_TRANSIT = 'transit'
REFUND_STATE_DONE = 'done'
# REFUND_STATE_REJECTED = 'rejected'
REFUND_STATE_CANCELED = 'canceled'
REFUND_STATE_CREATED = 'created'
REFUND_STATE_FAILED = 'failed'
REFUND_STATES = (
# (REFUND_STATE_REQUESTED, pgettext_lazy('refund_state', 'requested')),
# (REFUND_STATE_APPROVED, pgettext_lazy('refund_state', 'approved')),
(REFUND_STATE_EXTERNAL, pgettext_lazy('refund_state', 'started externally')),
(REFUND_STATE_CREATED, pgettext_lazy('refund_state', 'created')),
(REFUND_STATE_TRANSIT, pgettext_lazy('refund_state', 'in transit')),
(REFUND_STATE_DONE, pgettext_lazy('refund_state', 'done')),
(REFUND_STATE_FAILED, pgettext_lazy('refund_state', 'failed')),
# (REFUND_STATE_REJECTED, pgettext_lazy('refund_state', 'rejected')),
(REFUND_STATE_CANCELED, pgettext_lazy('refund_state', 'canceled')),
)
REFUND_SOURCE_BUYER = 'buyer'
REFUND_SOURCE_ADMIN = 'admin'
REFUND_SOURCE_EXTERNAL = 'external'
REFUND_SOURCES = (
(REFUND_SOURCE_ADMIN, pgettext_lazy('refund_source', 'Organizer')),
(REFUND_SOURCE_BUYER, pgettext_lazy('refund_source', 'Customer')),
(REFUND_SOURCE_EXTERNAL, pgettext_lazy('refund_source', 'External')),
)
local_id = models.PositiveIntegerField()
state = models.CharField(
max_length=190, choices=REFUND_STATES
)
source = models.CharField(
max_length=190, choices=REFUND_SOURCES
)
amount = models.DecimalField(
decimal_places=2, max_digits=10,
verbose_name=_("Amount")
)
order = models.ForeignKey(
Order,
verbose_name=_("Order"),
related_name='refunds',
on_delete=models.PROTECT
)
payment = models.ForeignKey(
OrderPayment,
null=True, blank=True,
related_name='refunds',
on_delete=models.PROTECT
)
created = models.DateTimeField(
auto_now_add=True
)
execution_date = models.DateTimeField(
null=True, blank=True
)
provider = models.CharField(
null=True, blank=True,
max_length=255,
verbose_name=_("Payment provider")
)
info = models.TextField(
verbose_name=_("Payment information"),
null=True, blank=True
)
class Meta:
ordering = ('local_id',)
@property
def info_data(self):
"""
This property allows convenient access to the data stored in the ``info``
attribute by automatically encoding and decoding the content as JSON.
"""
return json.loads(self.info) if self.info else {}
@info_data.setter
def info_data(self, d):
self.info = json.dumps(d)
@cached_property
def payment_provider(self):
"""
Cached access to an instance of the payment provider in use.
"""
return self.order.event.get_payment_providers().get(self.provider)
@transaction.atomic
def done(self, user=None, auth=None):
"""
Marks the refund as complete. This does not modify the state of the order.
:param user: The user who performed the change
:param user: The API auth token that performed the change
"""
self.state = self.REFUND_STATE_DONE
self.execution_date = self.execution_date or now()
self.save()
self.order.log_action('pretix.event.order.refund.done', {
'local_id': self.local_id,
'provider': self.provider,
}, user=user, auth=auth)
if self.payment and self.payment.refunded_amount >= self.payment.amount:
self.payment.state = OrderPayment.PAYMENT_STATE_REFUNDED
self.payment.save(update_fields=['state'])
@property
def full_id(self):
"""
The full human-readable ID of this refund, constructed by the order code and the ``local_id``
field with ``-R-`` in between.
:return:
"""
return '{}-R-{}'.format(self.order.code, self.local_id)
def save(self, *args, **kwargs):
if not self.local_id:
self.local_id = (self.order.refunds.aggregate(m=Max('local_id'))['m'] or 0) + 1
super().save(*args, **kwargs)
class OrderFee(models.Model):
"""
An OrderFee objet represents a fee that is added to the order total independently of
An OrderFee object represents a fee that is added to the order total independently of
the actual positions. This might for example be a payment or a shipping fee.
:param value: Gross price of this fee
:type value: Decimal
:param order: Order this fee is charged with
:type order: Order
:param fee_type: The type of the fee, currently ``payment``, ``shipping``, ``service``, ``giftcard``, or ``other``.
:type fee_type: str
:param description: A human-readable description of the fee
:type description: str
:param internal_type: An internal string to group fees by, e.g. the identifier string of a payment provider
:type internal_type: str
:param tax_rate: The tax rate applied to this fee
:type tax_rate: Decimal
:param tax_rule: The tax rule applied to this fee
:type tax_rule: TaxRule
:param tax_value: The tax amount included in the price
:type tax_value: Decimal
"""
FEE_TYPE_PAYMENT = "payment"
FEE_TYPE_SHIPPING = "shipping"
@@ -813,6 +1289,18 @@ class OrderPosition(AbstractPosition):
:param order: The order this position is a part of
:type order: Order
:param positionid: A local ID of this position, counted for each order individually
:type positionid: int
:param tax_rate: The tax rate applied to this position
:type tax_rate: Decimal
:param tax_rule: The tax rule applied to this position
:type tax_rule: TaxRule
:param tax_value: The tax amount included in the price
:type tax_value: Decimal
:param secret: The secret used for ticket QR codes
:type secret: str
:param pseudonymization_id: The QR code content for lead scanning
:type pseudonymization_id: str
"""
positionid = models.PositiveIntegerField(default=1)
order = models.ForeignKey(

View File

@@ -229,6 +229,12 @@ def register_default_notification_types(sender, **kwargs):
_('Order changed'),
_('Order {order.code} has been changed.')
),
ParametrizedOrderNotificationType(
sender,
'pretix.event.order.refund.created.externally',
_('External refund of payment'),
_('An external refund for {order.code} has occurred.')
),
ParametrizedOrderNotificationType(
sender,
'pretix.event.order.refunded',

View File

@@ -1,3 +1,4 @@
import json
import logging
from collections import OrderedDict
from decimal import ROUND_HALF_UP, Decimal
@@ -6,7 +7,6 @@ from typing import Any, Dict, Union
import pytz
from django import forms
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import ImproperlyConfigured
from django.dispatch import receiver
from django.forms import Form
@@ -14,13 +14,18 @@ from django.http import HttpRequest
from django.template.loader import get_template
from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from i18nfield.forms import I18nFormField, I18nTextarea
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
from i18nfield.strings import LazyI18nString
from pretix.base.models import CartPosition, Event, Order, Quota
from pretix.base.forms import PlaceholderValidator
from pretix.base.models import (
CartPosition, Event, Order, OrderPayment, OrderRefund, Quota,
)
from pretix.base.reldate import RelativeDateField, RelativeDateWrapper
from pretix.base.settings import SettingsSandbox
from pretix.base.signals import register_payment_providers
from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.rich_text import rich_text
from pretix.helpers.money import DecimalTextInput
from pretix.presale.views import get_cart_total
from pretix.presale.views.cart import get_or_create_cart_id
@@ -131,6 +136,16 @@ class BasePaymentProvider:
"""
raise NotImplementedError() # NOQA
@property
def abort_pending_allowed(self) -> bool:
"""
Whether or not a user can abort a payment in pending start to switch to another
payment method. This returns ``False`` by default which is no guarantee that
aborting a pending payment can never happen, it just hides the frontend button
to avoid users accidentally committing double payments.
"""
return False
@property
def settings_form_fields(self) -> dict:
"""
@@ -360,7 +375,7 @@ class BasePaymentProvider:
def payment_form_render(self, request: HttpRequest) -> str:
"""
When the user selects this provider as his preferred payment method,
When the user selects this provider as their preferred payment method,
they will be shown the HTML you return from this method.
The default implementation will call :py:meth:`checkout_form`
@@ -375,8 +390,8 @@ class BasePaymentProvider:
def checkout_confirm_render(self, request) -> str:
"""
If the user has successfully filled in his payment data, they will be redirected
to a confirmation page which lists all details of his order for a final review.
If the user has successfully filled in their payment data, they will be redirected
to a confirmation page which lists all details of their order for a final review.
This method should return the HTML which should be displayed inside the
'Payment' box on this page.
@@ -385,11 +400,19 @@ class BasePaymentProvider:
"""
raise NotImplementedError() # NOQA
def payment_pending_render(self, request: HttpRequest, payment: OrderPayment) -> str:
"""
Render customer-facing instructions on how to proceed with a pending payment
:return: HTML
"""
return ""
def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str]:
"""
Will be called after the user selects this provider as his payment method.
Will be called after the user selects this provider as their payment method.
If you provided a form to the user to enter payment data, this method should
at least store the user's input into his session.
at least store the user's input into their session.
This method should return ``False`` if the user's input was invalid, ``True``
if the input was valid and the frontend should continue with default behavior
@@ -404,7 +427,7 @@ class BasePaymentProvider:
If your payment method requires you to redirect the user to an external provider,
this might be the place to do so.
.. IMPORTANT:: If this is called, the user has not yet confirmed his or her order.
.. IMPORTANT:: If this is called, the user has not yet confirmed their order.
You may NOT do anything which actually moves money.
:param cart: This dictionary contains at least the following keys:
@@ -439,26 +462,29 @@ class BasePaymentProvider:
"""
raise NotImplementedError() # NOQA
def payment_perform(self, request: HttpRequest, order: Order) -> str:
def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str:
"""
After the user has confirmed their purchase, this method will be called to complete
the payment process. This is the place to actually move the money if applicable.
If you need any special behavior, you can return a string
You will be passed an :py:class:`pretix.base.models.OrderPayment` object that contains
the amount of money that should be paid.
If you need any special behavior, you can return a string
containing the URL the user will be redirected to. If you are done with your process
you should return the user to the order's detail page.
If the payment is completed, you should call ``pretix.base.services.orders.mark_order_paid(order, provider, info)``
with ``provider`` being your :py:attr:`identifier` and ``info`` being any string
you might want to store for later usage. Please note that ``mark_order_paid`` might
raise a ``Quota.QuotaExceededException`` if (and only if) the payment term of this
order is over and some of the items are sold out. You should use the exception message
to display a meaningful error to the user.
If the payment is completed, you should call ``payment.confirm()``. Please note that ``this`` might
raise a ``Quota.QuotaExceededException`` if (and only if) the payment term of this order is over and
some of the items are sold out. You should use the exception message to display a meaningful error
to the user.
The default implementation just returns ``None`` and therefore leaves the
order unpaid. The user will be redirected to the order's detail page by default.
On errors, you should raise a ``PaymentException``.
:param order: The order object
:param payment: An ``OrderPayment`` instance
"""
return None
@@ -472,19 +498,6 @@ class BasePaymentProvider:
"""
return ""
def order_pending_render(self, request: HttpRequest, order: Order) -> str:
"""
If the user visits a detail page of an order which has not yet been paid but
this payment method was selected during checkout, this method will be called
to provide HTML content for the 'payment' box on the page.
It should contain instructions on how to continue with the payment process,
either in form of text or buttons/links/etc.
:param order: The order object
"""
raise NotImplementedError() # NOQA
def order_change_allowed(self, order: Order) -> bool:
"""
Will be called to check whether it is allowed to change the payment method of
@@ -494,39 +507,16 @@ class BasePaymentProvider:
:param order: The order object
"""
if self.settings._total_max is not None and order.total > Decimal(self.settings._total_max):
ps = order.pending_sum
if self.settings._total_max is not None and ps > Decimal(self.settings._total_max):
return False
if self.settings._total_min is not None and order.total < Decimal(self.settings._total_min):
if self.settings._total_min is not None and ps < Decimal(self.settings._total_min):
return False
return self._is_still_available(order=order)
def order_can_retry(self, order: Order) -> bool:
"""
Will be called if the user views the detail page of an unpaid order to determine
whether the user should be presented with an option to retry the payment. The default
implementation always returns False.
If you want to enable retrials for your payment method, the best is to just return
``self._is_still_available()`` from this method to disable it as soon as the method
gets disabled or the methods end date is reached.
The retry workflow is also used if a user switches to this payment method for an existing
order!
:param order: The order object
"""
return False
def retry_prepare(self, request: HttpRequest, order: Order) -> Union[bool, str]:
"""
Deprecated, use order_prepare instead
"""
raise DeprecationWarning('retry_prepare is deprecated, use order_prepare instead')
return self.order_prepare(request, order)
def order_prepare(self, request: HttpRequest, order: Order) -> Union[bool, str]:
def payment_prepare(self, request: HttpRequest, payment: OrderPayment) -> Union[bool, str]:
"""
Will be called if the user retries to pay an unpaid order (after the user filled in
e.g. the form returned by :py:meth:`payment_form`) or if the user changes the payment
@@ -547,22 +537,9 @@ class BasePaymentProvider:
else:
return False
def order_paid_render(self, request: HttpRequest, order: Order) -> str:
def payment_control_render(self, request: HttpRequest, payment: OrderPayment) -> str:
"""
Will be called if the user views the detail page of a paid order which is
associated with this payment provider.
It should return HTML code which should be displayed to the user or None,
if there is nothing to say (like the default implementation does).
:param order: The order object
"""
return None
def order_control_render(self, request: HttpRequest, order: Order) -> str:
"""
Will be called if the *event administrator* views the detail page of an order
which is associated with this payment provider.
Will be called if the *event administrator* views the details of a payment.
It should return HTML code containing information regarding the current payment
status and, if applicable, next steps.
@@ -571,62 +548,44 @@ class BasePaymentProvider:
:param order: The order object
"""
return _('Payment provider: %s' % self.verbose_name)
return ''
def order_control_refund_render(self, order: Order, request: HttpRequest=None) -> str:
def payment_refund_supported(self, payment: OrderPayment) -> bool:
"""
Will be called if the event administrator clicks an order's 'refund' button.
This can be used to display information *before* the order is being refunded.
It should return HTML code which should be displayed to the user. It should
contain information about to which extend the money will be refunded
automatically.
:param order: The order object
:param request: The HTTP request
.. versionchanged:: 1.6
The parameter ``request`` has been added.
Will be called to check if the provider supports automatic refunding for this
payment.
"""
return '<div class="alert alert-warning">%s</div>' % _('The money can not be automatically refunded, '
'please transfer the money back manually.')
return False
def order_control_refund_perform(self, request: HttpRequest, order: Order) -> Union[bool, str]:
def payment_partial_refund_supported(self, payment: OrderPayment) -> bool:
"""
Will be called if the event administrator confirms the refund.
This should transfer the money back (if possible). You can return the URL the
user should be redirected to if you need special behavior or None to continue
with default behavior.
On failure, you should use Django's message framework to display an error message
to the user.
The default implementation sets the Order's state to refunded and shows a success
message.
:param request: The HTTP request
:param order: The order object
Will be called to check if the provider supports automatic partial refunding for this
payment.
"""
from pretix.base.services.orders import mark_order_refunded
return False
mark_order_refunded(order, user=request.user)
messages.success(request, _('The order has been marked as refunded. Please transfer the money '
'back to the buyer manually.'))
def execute_refund(self, refund: OrderRefund):
"""
Will be called to execute an refund. Note that refunds have an amount property and can be partial.
def shred_payment_info(self, order: Order):
This should transfer the money back (if possible).
On success, you should call ``refund.done()``.
On failure, you should raise a PaymentException.
"""
raise PaymentException(_('Automatic refunds are not supported by this payment provider.'))
def shred_payment_info(self, obj: Union[OrderPayment, OrderRefund]):
"""
When personal data is removed from an event, this method is called to scrub payment-related data
from an order. By default, it removes all info from the ``payment_info`` attribute. You can override
from a payment or refund. By default, it removes all info from the ``info`` attribute. You can override
this behavior if you want to retain attributes that are not personal data on their own, i.e. a
reference to a transaction in an external system. You can also override this to scrub more data, e.g.
data from external sources that is saved in LogEntry objects or other places.
:param order: An order
"""
order.payment_info = None
order.save(update_fields=['payment_info'])
obj.info = '{}'
obj.save(update_fields=['info'])
class PaymentException(Exception):
@@ -634,25 +593,13 @@ class PaymentException(Exception):
class FreeOrderProvider(BasePaymentProvider):
@property
def is_implicit(self) -> bool:
return True
@property
def is_enabled(self) -> bool:
return True
@property
def identifier(self) -> str:
return "free"
is_implicit = True
is_enabled = True
identifier = "free"
def checkout_confirm_render(self, request: HttpRequest) -> str:
return _("No payment is required as this order only includes products which are free of charge.")
def order_pending_render(self, request: HttpRequest, order: Order) -> str:
pass
def payment_is_valid_session(self, request: HttpRequest) -> bool:
return True
@@ -660,10 +607,9 @@ class FreeOrderProvider(BasePaymentProvider):
def verbose_name(self) -> str:
return _("Free of charge")
def payment_perform(self, request: HttpRequest, order: Order):
from pretix.base.services.orders import mark_order_paid
def execute_payment(self, request: HttpRequest, payment: OrderPayment):
try:
mark_order_paid(order, 'free', send_mail=False)
payment.confirm()
except Quota.QuotaExceededException as e:
raise PaymentException(str(e))
@@ -671,32 +617,7 @@ class FreeOrderProvider(BasePaymentProvider):
def settings_form_fields(self) -> dict:
return {}
def order_control_refund_render(self, order: Order) -> str:
return ''
def order_control_refund_perform(self, request: HttpRequest, order: Order) -> Union[bool, str]:
"""
Will be called if the event administrator confirms the refund.
This should transfer the money back (if possible). You can return the URL the
user should be redirected to if you need special behavior or None to continue
with default behavior.
On failure, you should use Django's message framework to display an error message
to the user.
The default implementation sets the Order's state to refunded and shows a success
message.
:param request: The HTTP request
:param order: The order object
"""
from pretix.base.services.orders import mark_order_refunded
mark_order_refunded(order, user=request.user)
messages.success(request, _('The order has been marked as refunded.'))
def is_allowed(self, request: HttpRequest, total: Decimal) -> bool:
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
from .services.cart import get_fees
total = get_cart_total(request)
@@ -713,10 +634,9 @@ class BoxOfficeProvider(BasePaymentProvider):
identifier = "boxoffice"
verbose_name = _("Box office")
def payment_perform(self, request: HttpRequest, order: Order):
from pretix.base.services.orders import mark_order_paid
def execute_payment(self, request: HttpRequest, payment: OrderPayment):
try:
mark_order_paid(order, 'boxoffice', send_mail=False)
payment.confirm()
except Quota.QuotaExceededException as e:
raise PaymentException(str(e))
@@ -724,22 +644,136 @@ class BoxOfficeProvider(BasePaymentProvider):
def settings_form_fields(self) -> dict:
return {}
def order_control_refund_render(self, order: Order) -> str:
return ''
def order_control_refund_perform(self, request: HttpRequest, order: Order) -> Union[bool, str]:
from pretix.base.services.orders import mark_order_refunded
mark_order_refunded(order, user=request.user)
messages.success(request, _('The order has been marked as refunded.'))
def is_allowed(self, request: HttpRequest, total: Decimal) -> bool:
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
return False
def order_change_allowed(self, order: Order) -> bool:
return False
class ManualPayment(BasePaymentProvider):
identifier = 'manual'
verbose_name = _('Manual payment')
@property
def is_implicit(self):
return 'pretix.plugins.manualpayment' not in self.event.plugins
def is_allowed(self, request: HttpRequest, total: Decimal=None):
return 'pretix.plugins.manualpayment' in self.event.plugins
def order_change_allowed(self, order: Order):
return 'pretix.plugins.manualpayment' in self.event.plugins
@property
def public_name(self):
return str(self.settings.get('public_name', as_type=LazyI18nString))
@property
def settings_form_fields(self):
d = OrderedDict(
[
('public_name', I18nFormField(
label=_('Payment method name'),
widget=I18nTextInput,
)),
('checkout_description', I18nFormField(
label=_('Payment process description during checkout'),
help_text=_('This text will be shown during checkout when the user selects this payment method. '
'It should give a short explanation on this payment method.'),
widget=I18nTextarea,
)),
('email_instructions', I18nFormField(
label=_('Payment process description in order confirmation emails'),
help_text=_('This text will be included for the {payment_info} placeholder in order confirmation '
'mails. It should instruct the user on how to proceed with the payment. You can use'
'the placeholders {order}, {total}, {currency} and {total_with_currency}'),
widget=I18nTextarea,
validators=[PlaceholderValidator(['{order}', '{total}', '{currency}', '{total_with_currency}'])],
)),
('pending_description', I18nFormField(
label=_('Payment process description for pending orders'),
help_text=_('This text will be shown on the order confirmation page for pending orders. '
'It should instruct the user on how to proceed with the payment. You can use'
'the placeholders {order}, {total}, {currency} and {total_with_currency}'),
widget=I18nTextarea,
validators=[PlaceholderValidator(['{order}', '{total}', '{currency}', '{total_with_currency}'])],
)),
] + list(super().settings_form_fields.items())
)
d.move_to_end('_enabled', last=False)
return d
def payment_form_render(self, request) -> str:
return rich_text(
str(self.settings.get('checkout_description', as_type=LazyI18nString))
)
def checkout_prepare(self, request, total):
return True
def payment_is_valid_session(self, request):
return True
def checkout_confirm_render(self, request):
return self.payment_form_render(request)
def format_map(self, order):
return {
'order': order.code,
'total': order.total,
'currency': self.event.currency,
'total_with_currency': money_filter(order.total, self.event.currency)
}
def order_pending_mail_render(self, order) -> str:
msg = str(self.settings.get('email_instructions', as_type=LazyI18nString)).format_map(self.format_map(order))
return msg
def order_pending_render(self, request, order) -> str:
return rich_text(
str(self.settings.get('pending_description', as_type=LazyI18nString)).format_map(self.format_map(order))
)
class OffsettingProvider(BasePaymentProvider):
is_enabled = True
identifier = "offsetting"
verbose_name = _("Offsetting")
is_implicit = True
def execute_payment(self, request: HttpRequest, payment: OrderPayment):
try:
payment.confirm()
except Quota.QuotaExceededException as e:
raise PaymentException(str(e))
def execute_refund(self, refund: OrderRefund):
code = refund.info_data['orders'][0]
order = self.event.orders.get(code=code)
p = order.payments.create(
state=OrderPayment.PAYMENT_STATE_PENDING,
amount=refund.amount,
payment_date=now(),
provider='offsetting',
info=json.dumps({'orders': [refund.order.code]})
)
p.confirm()
@property
def settings_form_fields(self) -> dict:
return {}
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
return False
def order_change_allowed(self, order: Order) -> bool:
return False
def payment_control_render(self, request: HttpRequest, payment: OrderPayment) -> str:
return _('Balanced against orders: %s' % ', '.join(payment.info_data['orders']))
@receiver(register_payment_providers, dispatch_uid="payment_free")
def register_payment_provider(sender, **kwargs):
return [FreeOrderProvider, BoxOfficeProvider]
return [FreeOrderProvider, BoxOfficeProvider, OffsettingProvider, ManualPayment]

View File

@@ -18,7 +18,9 @@ from django.utils.translation import pgettext, ugettext as _
from i18nfield.strings import LazyI18nString
from pretix.base.i18n import language
from pretix.base.models import Invoice, InvoiceAddress, InvoiceLine, Order
from pretix.base.models import (
Invoice, InvoiceAddress, InvoiceLine, Order, OrderPayment,
)
from pretix.base.models.tax import EU_CURRENCIES
from pretix.base.services.async import TransactionAwareTask
from pretix.base.settings import GlobalSettingsObject
@@ -31,16 +33,19 @@ logger = logging.getLogger(__name__)
@transaction.atomic
def build_invoice(invoice: Invoice) -> Invoice:
with language(invoice.locale):
payment_provider = invoice.event.get_payment_providers().get(invoice.order.payment_provider)
lp = invoice.order.payments.last()
open_payment = None
if lp and lp.state not in (OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED):
open_payment = lp
with language(invoice.locale):
invoice.invoice_from = invoice.event.settings.get('invoice_address_from')
introductory = invoice.event.settings.get('invoice_introductory_text', as_type=LazyI18nString)
additional = invoice.event.settings.get('invoice_additional_text', as_type=LazyI18nString)
footer = invoice.event.settings.get('invoice_footer_text', as_type=LazyI18nString)
if payment_provider:
payment = payment_provider.render_invoice_text(invoice.order)
if open_payment and open_payment.payment_provider:
payment = open_payment.payment_provider.render_invoice_text(invoice.order)
else:
payment = ""

View File

@@ -13,6 +13,7 @@ from django.db import transaction
from django.db.models import F, Max, Q, Sum
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import ugettext as _
@@ -21,12 +22,12 @@ from pretix.base.i18n import (
LazyCurrencyNumber, LazyDate, LazyLocaleException, LazyNumber, language,
)
from pretix.base.models import (
CartPosition, Event, Item, ItemVariation, Order, OrderPosition, Quota,
User, Voucher,
CartPosition, Event, Item, ItemVariation, Order, OrderPayment,
OrderPosition, Quota, User, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import (
CachedCombinedTicket, CachedTicket, InvoiceAddress, OrderFee,
CachedCombinedTicket, CachedTicket, InvoiceAddress, OrderFee, OrderRefund,
generate_position_secret, generate_secret,
)
from pretix.base.models.organizer import TeamAPIToken
@@ -40,8 +41,7 @@ from pretix.base.services.locking import LockTimeoutException
from pretix.base.services.mail import SendMailException
from pretix.base.services.pricing import get_price
from pretix.base.signals import (
allow_ticket_download, order_fee_calculation, order_paid, order_placed,
periodic_task,
allow_ticket_download, order_fee_calculation, order_placed, periodic_task,
)
from pretix.celery_app import app
from pretix.multidomain.urlreverse import build_absolute_uri
@@ -79,99 +79,8 @@ error_messages = {
logger = logging.getLogger(__name__)
def mark_order_paid(order: Order, provider: str=None, info: str=None, date: datetime=None, manual: bool=None,
force: bool=False, send_mail: bool=True, user: User=None, mail_text='',
count_waitinglist=True, auth=None) -> Order:
"""
Marks an order as paid. This sets the payment provider, info and date and returns
the order object.
:param provider: The payment provider that marked this as paid
:type provider: str
:param info: The information to store in order.payment_info
:type info: str
:param date: The date the payment was received (if you pass ``None``, the current
time will be used).
:type date: datetime
:param force: Whether this payment should be marked as paid even if no remaining
quota is available (default: ``False``).
:type force: boolean
:param send_mail: Whether an email should be sent to the user about this event (default: ``True``).
:type send_mail: boolean
:param user: The user that performed the change
:param mail_text: Additional text to be included in the email
:type mail_text: str
:raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False``
"""
if order.status == Order.STATUS_PAID:
return order
with order.event.lock() as now_dt:
can_be_paid = order._can_be_paid(count_waitinglist=count_waitinglist)
if not force and can_be_paid is not True:
raise Quota.QuotaExceededException(can_be_paid)
order.payment_provider = provider or order.payment_provider
order.payment_info = info or order.payment_info
order.payment_date = date or now_dt
if manual is not None:
order.payment_manual = manual
order.status = Order.STATUS_PAID
order.save()
order.log_action('pretix.event.order.paid', {
'provider': provider,
'info': info,
'date': date or now_dt,
'manual': manual,
'force': force
}, user=user, auth=auth)
order_paid.send(order.event, order=order)
invoice = None
if invoice_qualified(order):
invoices = order.invoices.filter(is_cancellation=False).count()
cancellations = order.invoices.filter(is_cancellation=True).count()
gen_invoice = (
(invoices == 0 and order.event.settings.get('invoice_generate') in ('True', 'paid')) or
0 < invoices <= cancellations
)
if gen_invoice:
invoice = generate_invoice(
order,
trigger_pdf=not send_mail or not order.event.settings.invoice_email_attachment
)
if send_mail:
with language(order.locale):
try:
invoice_name = order.invoice_address.name
invoice_company = order.invoice_address.company
except InvoiceAddress.DoesNotExist:
invoice_name = ""
invoice_company = ""
email_template = order.event.settings.mail_text_order_paid
email_context = {
'event': order.event.name,
'url': build_absolute_uri(order.event, 'presale:event.order', kwargs={
'order': order.code,
'secret': order.secret
}),
'downloads': order.event.settings.get('ticket_download', as_type=bool),
'invoice_name': invoice_name,
'invoice_company': invoice_company,
'payment_info': mail_text
}
email_subject = _('Payment received for your order: %(code)s') % {'code': order.code}
try:
order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_paid', user,
invoices=[invoice] if invoice and order.event.settings.invoice_email_attachment else []
)
except SendMailException:
logger.exception('Order paid email could not be sent')
return order
def mark_order_paid(*args, **kwargs):
raise NotImplementedError("This method is no longer supported since pretix 1.17.")
def extend_order(order: Order, new_date: datetime, force: bool=False, user: User=None, auth=None):
@@ -215,7 +124,7 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
@transaction.atomic
def mark_order_refunded(order, user=None, api_token=None):
def mark_order_refunded(order, user=None, auth=None, api_token=None):
"""
Mark this order as refunded. This sets the payment status and returns the order object.
:param order: The order to change
@@ -229,7 +138,7 @@ def mark_order_refunded(order, user=None, api_token=None):
order.status = Order.STATUS_REFUNDED
order.save()
order.log_action('pretix.event.order.refunded', user=user, api_token=api_token)
order.log_action('pretix.event.order.refunded', user=user, auth=auth or api_token)
i = order.invoices.filter(is_cancellation=False).last()
if i:
generate_cancellation(i)
@@ -434,20 +343,22 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid
fees = []
total = sum([c.price for c in positions])
payment_fee = payment_provider.calculate_fee(total)
pf = None
if payment_fee:
fees.append(OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee,
internal_type=payment_provider.identifier))
pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee,
internal_type=payment_provider.identifier)
fees.append(pf)
for recv, resp in order_fee_calculation.send(sender=event, invoice_address=address, total=total,
meta_info=meta_info, positions=positions):
fees += resp
return fees
return fees, pf
def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime,
payment_provider: BasePaymentProvider, locale: str=None, address: InvoiceAddress=None,
meta_info: dict=None):
fees = _get_fees(positions, payment_provider, address, meta_info, event)
fees, pf = _get_fees(positions, payment_provider, address, meta_info, event)
total = sum([c.price for c in positions]) + sum([c.value for c in fees])
with transaction.atomic():
@@ -458,7 +369,6 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
datetime=now_dt,
locale=locale,
total=total,
payment_provider=payment_provider.identifier,
meta_info=json.dumps(meta_info or {}),
)
order.set_expires(now_dt, event.subevents.filter(id__in=[p.subevent_id for p in positions]))
@@ -479,6 +389,13 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
fee.tax_rule = None # TODO: deprecate
fee.save()
order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED,
provider=payment_provider,
amount=total,
fee=pf
)
OrderPosition.transform_cart_positions(positions, order)
order.log_action('pretix.event.order.placed')
if meta_info:
@@ -528,7 +445,7 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
# send_mail will trigger PDF generation later
if order.email:
if order.payment_provider == 'free':
if payment_provider == 'free':
email_template = event.settings.mail_text_order_free
log_entry = 'pretix.event.order.email.order_free'
else:
@@ -678,8 +595,6 @@ class OrderChangeManager:
'not_pending_or_paid': _('Only pending or paid orders can be changed.'),
'paid_to_free_exceeded': _('This operation would make the order free and therefore immediately paid, however '
'no quota is available.'),
'paid_price_change': _('Currently, paid orders can only be changed in a way that does not change the total '
'price of the order as partial payments or refunds are not yet supported.'),
'addon_to_required': _('This is an add-on product, please select the base position it should be added to.'),
'addon_invalid': _('The selected base position does not allow you to add this product as an add-on.'),
'subevent_required': _('You need to choose a subevent for the new position.'),
@@ -840,28 +755,43 @@ class OrderChangeManager:
raise OrderError(self.error_messages['free_to_paid'])
def _check_paid_price_change(self):
if self.order.status == Order.STATUS_PAID and self._totaldiff != 0:
raise OrderError(self.error_messages['paid_price_change'])
if self.order.status == Order.STATUS_PAID and self._totaldiff > 0:
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()
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff < 0:
if self.order.pending_sum <= Decimal('0.00'):
self.order.status = Order.STATUS_PAID
self.order.save()
def _check_paid_to_free(self):
if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)):
# if the order becomes free, mark it paid using the 'free' provider
# this could happen if positions have been made cheaper or removed (_totaldiff < 0)
# or positions got split off to a new order (split_order with positive total)
p = self.order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED,
provider='free',
amount=0,
fee=None
)
try:
mark_order_paid(
self.order, 'free', send_mail=False, count_waitinglist=False,
user=self.user
)
p.confirm(send_mail=False, count_waitinglist=False, user=self.user)
except Quota.QuotaExceededException:
raise OrderError(self.error_messages['paid_to_free_exceeded'])
if self.split_order and self.split_order.total == 0:
p = self.split_order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED,
provider='free',
amount=0,
fee=None
)
try:
mark_order_paid(
self.split_order, 'free', send_mail=False, count_waitinglist=False,
user=self.user
)
p.confirm(send_mail=False, count_waitinglist=False, user=self.user)
except Quota.QuotaExceededException:
raise OrderError(self.error_messages['paid_to_free_exceeded'])
@@ -1002,7 +932,11 @@ class OrderChangeManager:
split_order.total = sum([p.price for p in split_positions])
if split_order.total != Decimal('0.00') and self.order.status != Order.STATUS_PAID:
payment_fee = self._get_payment_provider().calculate_fee(split_order.total)
pp = self._get_payment_provider()
if pp:
payment_fee = pp.calculate_fee(split_order.total)
else:
payment_fee = Decimal('0.00')
fee = split_order.fees.get_or_create(fee_type=OrderFee.FEE_TYPE_PAYMENT, defaults={'value': 0})[0]
fee.value = payment_fee
fee._calculate_tax()
@@ -1021,41 +955,89 @@ class OrderChangeManager:
split_order.save()
if split_order.status == Order.STATUS_PAID:
split_order.payments.create(
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
amount=split_order.total,
payment_date=now(),
provider='offsetting',
info=json.dumps({'orders': [self.order.code]})
)
self.order.refunds.create(
state=OrderRefund.REFUND_STATE_DONE,
amount=split_order.total,
execution_date=now(),
provider='offsetting',
info=json.dumps({'orders': [split_order.code]})
)
if split_order.total != Decimal('0.00') and self.order.invoices.filter(is_cancellation=False).last():
generate_invoice(split_order)
return split_order
def _recalculate_total_and_payment_fee(self):
self.order.total = sum([p.price for p in self.order.positions.all()])
@cached_property
def open_payment(self):
lp = self.order.payments.last()
if lp and lp.state not in (OrderPayment.PAYMENT_STATE_CONFIRMED,
OrderPayment.PAYMENT_STATE_REFUNDED):
return lp
if self.order.status != Order.STATUS_PAID:
# Do not change payment fees of paid orders
payment_fee = Decimal('0.00')
if self.order.total != 0:
prov = self._get_payment_provider()
@cached_property
def completed_payment_sum(self):
payment_sum = self.order.payments.filter(
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
refund_sum = self.order.refunds.filter(
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT, OrderRefund.REFUND_STATE_DONE)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
return payment_sum - refund_sum
def _recalculate_total_and_payment_fee(self):
total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()])
payment_fee = Decimal('0.00')
if self.open_payment:
current_fee = Decimal('0.00')
fee = None
if self.open_payment.fee:
fee = self.open_payment.fee
current_fee = self.open_payment.fee.value
total -= current_fee
if self.order.pending_sum - current_fee != 0:
prov = self.open_payment.payment_provider
if prov:
payment_fee = prov.calculate_fee(self.order.total)
payment_fee = prov.calculate_fee(total - self.completed_payment_sum)
if payment_fee:
fee = self.order.fees.get_or_create(fee_type=OrderFee.FEE_TYPE_PAYMENT, defaults={'value': 0})[0]
fee = fee or OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, order=self.order)
fee.value = payment_fee
fee._calculate_tax()
fee.save()
else:
self.order.fees.filter(fee_type=OrderFee.FEE_TYPE_PAYMENT).delete()
if not self.open_payment.fee:
self.open_payment.fee = fee
self.open_payment.save()
elif fee:
fee.delete()
self.order.total += sum([f.value for f in self.order.fees.all()])
self.order.total = total + payment_fee
self.order.save()
def _payment_fee_diff(self):
prov = self._get_payment_provider()
if self.order.status != Order.STATUS_PAID and prov:
# payment fees of paid orders do not change
old_fee = OrderFee.objects.filter(order=self.order, fee_type=OrderFee.FEE_TYPE_PAYMENT).aggregate(s=Sum('value'))['s'] or 0
new_total = sum([p.price for p in self.order.positions.all()]) + self._totaldiff
if new_total != 0:
new_fee = prov.calculate_fee(new_total)
self._totaldiff += new_fee - old_fee
total = self.order.total + self._totaldiff
if self.open_payment:
current_fee = Decimal('0.00')
if self.open_payment and self.open_payment.fee:
current_fee = self.open_payment.fee.value
total -= current_fee
# Do not change payment fees of paid orders
payment_fee = Decimal('0.00')
if self.order.pending_sum - current_fee != 0:
prov = self.open_payment.payment_provider
if prov:
payment_fee = prov.calculate_fee(total - self.completed_payment_sum)
self._totaldiff += payment_fee - current_fee
def _reissue_invoice(self):
i = self.order.invoices.filter(is_cancellation=False).last()
@@ -1121,7 +1103,6 @@ class OrderChangeManager:
if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_PAID):
raise OrderError(self.error_messages['not_pending_or_paid'])
self._check_free_to_paid()
self._check_paid_price_change()
self._check_quotas()
self._check_complete_cancel()
self._perform_operations()
@@ -1129,6 +1110,7 @@ class OrderChangeManager:
self._reissue_invoice()
self._clear_tickets_cache()
self.order.touch()
self._check_paid_price_change()
self._check_paid_to_free()
if self.notify:
@@ -1144,9 +1126,12 @@ class OrderChangeManager:
CachedCombinedTicket.objects.filter(order=self.split_order).delete()
def _get_payment_provider(self):
pprov = self.order.event.get_payment_providers().get(self.order.payment_provider)
lp = self.order.payments.last()
if not lp:
return None
pprov = lp.payment_provider
if not pprov:
raise OrderError(error_messages['internal'])
return None
return pprov

View File

@@ -15,8 +15,8 @@ from pretix.api.serializers.order import (
from pretix.api.serializers.waitinglist import WaitingListSerializer
from pretix.base.i18n import LazyLocaleException
from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, OrderPosition,
QuestionAnswer,
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, OrderPayment,
OrderPosition, OrderRefund, QuestionAnswer,
)
from pretix.base.services.invoices import invoice_pdf_task
from pretix.base.signals import register_data_shredders
@@ -331,10 +331,14 @@ class PaymentInfoShredder(BaseDataShredder):
@transaction.atomic
def shred_data(self):
provs = self.event.get_payment_providers()
for o in self.event.orders.all():
pprov = provs.get(o.payment_provider)
for obj in OrderPayment.objects.filter(order__event=self.event):
pprov = provs.get(obj.provider)
if pprov:
pprov.shred_payment_info(o)
pprov.shred_payment_info(obj)
for obj in OrderRefund.objects.filter(order__event=self.event):
pprov = provs.get(obj.provider)
if pprov:
pprov.shred_payment_info(obj)
@receiver(register_data_shredders, dispatch_uid="shredders_builtin")

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'})

View File

@@ -3,18 +3,20 @@ import textwrap
from collections import OrderedDict
from django import forms
from django.http import HttpRequest
from django.template.loader import get_template
from django.utils.translation import ugettext_lazy as _
from i18nfield.fields import I18nFormField, I18nTextarea
from i18nfield.strings import LazyI18nString
from pretix.base.models import Order
from pretix.base.models import OrderPayment
from pretix.base.payment import BasePaymentProvider
class BankTransfer(BasePaymentProvider):
identifier = 'banktransfer'
verbose_name = _('Bank transfer')
abort_pending_allowed = True
@staticmethod
def form_field(**kwargs):
@@ -65,6 +67,9 @@ class BankTransfer(BasePaymentProvider):
def checkout_prepare(self, request, total):
return True
def payment_prepare(self, request: HttpRequest, payment: OrderPayment):
return True
def payment_is_valid_session(self, request):
return True
@@ -81,12 +86,12 @@ class BankTransfer(BasePaymentProvider):
}
return template.render(ctx)
def order_pending_render(self, request, order) -> str:
def payment_pending_render(self, request: HttpRequest, payment: OrderPayment):
template = get_template('pretixplugins/banktransfer/pending.html')
ctx = {
'event': self.event,
'order': order,
'code': self._code(order),
'code': self._code(payment.order),
'order': payment.order,
'details': self.settings.get('bank_details', as_type=LazyI18nString),
}
return template.render(ctx)
@@ -102,18 +107,18 @@ class BankTransfer(BasePaymentProvider):
'payment_info': payment_info, 'order': order}
return template.render(ctx)
def _code(self, order: Order):
def _code(self, order):
if self.settings.get('omit_hyphen', as_type=bool):
return self.event.slug.upper() + order.code
else:
return order.full_code
def shred_payment_info(self, order: Order):
if not order.payment_info:
def shred_payment_info(self, obj):
if not obj.info_data:
return
d = json.loads(order.payment_info)
d = obj.info_data
d['reference'] = ''
d['payer'] = ''
d['_shredded'] = True
order.payment_info = json.dumps(d)
order.save(update_fields=['payment_info'])
obj.info = json.dumps(d)
obj.save(update_fields=['info'])

View File

@@ -1,4 +1,3 @@
import json
import logging
import re
from decimal import Decimal
@@ -10,11 +9,10 @@ from django.db.models import Q
from django.utils.translation import ugettext_noop
from pretix.base.i18n import language
from pretix.base.models import Event, Order, Organizer, Quota
from pretix.base.models import Event, Order, OrderPayment, Organizer, Quota
from pretix.base.services.async import TransactionAwareTask
from pretix.base.services.locking import LockTimeoutException
from pretix.base.services.mail import SendMailException
from pretix.base.services.orders import mark_order_paid
from pretix.celery_app import app
from .models import BankImportJob, BankTransaction
@@ -50,7 +48,7 @@ def _handle_transaction(trans: BankTransaction, code: str, event: Event=None, or
trans.save()
return
if trans.order.status == Order.STATUS_PAID:
if trans.order.status == Order.STATUS_PAID and trans.order.pending_sum <= Decimal('0.00'):
trans.state = BankTransaction.STATE_DUPLICATE
elif trans.order.status == Order.STATUS_REFUNDED:
trans.state = BankTransaction.STATE_ERROR
@@ -58,17 +56,23 @@ def _handle_transaction(trans: BankTransaction, code: str, event: Event=None, or
elif trans.order.status == Order.STATUS_CANCELED:
trans.state = BankTransaction.STATE_ERROR
trans.message = ugettext_noop('The order has already been canceled.')
elif trans.amount != trans.order.total:
trans.state = BankTransaction.STATE_INVALID
trans.message = ugettext_noop('The transaction amount is incorrect.')
else:
p = trans.order.payments.get_or_create(
amount=trans.amount,
provider='banktransfer',
state__in=(OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING),
defaults={
'state': OrderPayment.PAYMENT_STATE_CREATED,
}
)[0]
p.info_data = {
'reference': trans.reference,
'date': trans.date,
'payer': trans.payer,
'trans_id': trans.pk
}
try:
mark_order_paid(trans.order, provider='banktransfer', info=json.dumps({
'reference': trans.reference,
'date': trans.date,
'payer': trans.payer,
'trans_id': trans.pk
}))
p.confirm()
except Quota.QuotaExceededException as e:
trans.state = BankTransaction.STATE_ERROR
trans.message = str(e)
@@ -77,6 +81,10 @@ def _handle_transaction(trans: BankTransaction, code: str, event: Event=None, or
trans.message = ugettext_noop('Problem sending email.')
else:
trans.state = BankTransaction.STATE_VALID
trans.order.payments.filter(
provider='banktransfer',
state__in=(OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING),
).update(state=OrderPayment.PAYMENT_STATE_CANCELED)
trans.save()

View File

@@ -1,22 +1,5 @@
{% load i18n %}
{% if payment_info and order.status == "p" %}
<p>{% blocktrans trimmed %}
This order has been paid via bank transfer.
{% endblocktrans %}</p>
{% elif order.status == "p" %}
<p>{% blocktrans trimmed %}
This order has been marked as paid via bank transfer manually.
{% endblocktrans %}</p>
{% elif order.status == "r" %}
<p>{% blocktrans trimmed %}
This order has been paid via bank transfer and marked as refunded.
{% endblocktrans %}</p>
{% else %}
<p>{% blocktrans trimmed %}
This order has been planned to be paid via bank transfer, but no payment has been received yet.
{% endblocktrans %}</p>
{% endif %}
{% if payment_info %}
<dl class="dl-horizontal">
<dt>{% trans "Payer" %}</dt>

View File

@@ -14,9 +14,8 @@ from django.utils.timezone import now
from django.utils.translation import ugettext as _
from django.views.generic import DetailView, ListView, View
from pretix.base.models import Order, Quota
from pretix.base.models import Order, OrderPayment, Quota
from pretix.base.services.mail import SendMailException
from pretix.base.services.orders import mark_order_paid
from pretix.base.settings import SettingsSandbox
from pretix.base.templatetags.money import money_filter
from pretix.control.permissions import (
@@ -66,15 +65,22 @@ class ActionView(View):
'message': _('The order has already been canceled.')
})
p = trans.order.payments.get_or_create(
amount=trans.amount,
provider='banktransfer',
state__in=(OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING),
defaults={
'state': OrderPayment.PAYMENT_STATE_CREATED,
}
)[0]
p.info_data = {
'reference': trans.reference,
'date': trans.date,
'payer': trans.payer,
'trans_id': trans.pk
}
try:
mark_order_paid(trans.order, provider='banktransfer', info=json.dumps({
'reference': trans.reference,
'date': trans.date,
'payer': trans.payer,
'trans_id': trans.pk
}), count_waitinglist=False)
trans.state = BankTransaction.STATE_VALID
trans.save()
p.confirm(user=self.request.user)
except Quota.QuotaExceededException as e:
return JsonResponse({
'status': 'error',
@@ -88,8 +94,12 @@ class ActionView(View):
else:
trans.state = BankTransaction.STATE_VALID
trans.save()
trans.order.payments.filter(
provider='banktransfer',
state__in=(OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING),
).update(state=OrderPayment.PAYMENT_STATE_CANCELED)
return JsonResponse({
'status': 'ok'
'status': 'ok',
})
def _assign(self, trans, code):

View File

@@ -14,8 +14,5 @@ class ManualPaymentApp(AppConfig):
version = version
description = _("This plugin adds a customizable payment method for manual processing.")
def ready(self):
from . import signals # NOQA
default_app_config = 'pretix.plugins.manualpayment.ManualPaymentApp'

View File

@@ -1,92 +0,0 @@
from collections import OrderedDict
from django.template.loader import get_template
from django.utils.translation import ugettext_lazy as _
from i18nfield.fields import I18nFormField, I18nTextarea, I18nTextInput
from i18nfield.strings import LazyI18nString
from pretix.base.forms import PlaceholderValidator
from pretix.base.payment import BasePaymentProvider
from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.rich_text import rich_text
class ManualPayment(BasePaymentProvider):
identifier = 'manual'
verbose_name = _('Manual payment')
@property
def public_name(self):
return str(self.settings.get('public_name', as_type=LazyI18nString))
@property
def settings_form_fields(self):
d = OrderedDict(
[
('public_name', I18nFormField(
label=_('Payment method name'),
widget=I18nTextInput,
)),
('checkout_description', I18nFormField(
label=_('Payment process description during checkout'),
help_text=_('This text will be shown during checkout when the user selects this payment method. '
'It should give a short explanation on this payment method.'),
widget=I18nTextarea,
)),
('email_instructions', I18nFormField(
label=_('Payment process description in order confirmation emails'),
help_text=_('This text will be included for the {payment_info} placeholder in order confirmation '
'mails. It should instruct the user on how to proceed with the payment. You can use'
'the placeholders {order}, {total}, {currency} and {total_with_currency}'),
widget=I18nTextarea,
validators=[PlaceholderValidator(['{order}', '{total}', '{currency}', '{total_with_currency}'])],
)),
('pending_description', I18nFormField(
label=_('Payment process description for pending orders'),
help_text=_('This text will be shown on the order confirmation page for pending orders. '
'It should instruct the user on how to proceed with the payment. You can use'
'the placeholders {order}, {total}, {currency} and {total_with_currency}'),
widget=I18nTextarea,
validators=[PlaceholderValidator(['{order}', '{total}', '{currency}', '{total_with_currency}'])],
)),
] + list(super().settings_form_fields.items())
)
d.move_to_end('_enabled', last=False)
return d
def payment_form_render(self, request) -> str:
return rich_text(
str(self.settings.get('checkout_description', as_type=LazyI18nString))
)
def checkout_prepare(self, request, total):
return True
def payment_is_valid_session(self, request):
return True
def checkout_confirm_render(self, request):
return self.payment_form_render(request)
def format_map(self, order):
return {
'order': order.code,
'total': order.total,
'currency': self.event.currency,
'total_with_currency': money_filter(order.total, self.event.currency)
}
def order_pending_mail_render(self, order) -> str:
msg = str(self.settings.get('email_instructions', as_type=LazyI18nString)).format_map(self.format_map(order))
return msg
def order_pending_render(self, request, order) -> str:
return rich_text(
str(self.settings.get('pending_description', as_type=LazyI18nString)).format_map(self.format_map(order))
)
def order_control_render(self, request, order) -> str:
template = get_template('pretixplugins/manualpayment/control.html')
ctx = {'request': request, 'event': self.event,
'order': order}
return template.render(ctx)

View File

@@ -1,10 +0,0 @@
from django.dispatch import receiver
from pretix.base.signals import register_payment_providers
from .payment import ManualPayment
@receiver(register_payment_providers, dispatch_uid="payment_manual")
def register_payment_provider(sender, **kwargs):
return ManualPayment

View File

@@ -1,11 +0,0 @@
{% load i18n %}
{% if order.status == "p" %}
<p>{% blocktrans trimmed %}
This order has been paid manually.
{% endblocktrans %}</p>
{% else %}
<p>{% blocktrans trimmed %}
This order has been planned to be paid manually, but is not marked as paid.
{% endblocktrans %}</p>
{% endif %}

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.13 on 2018-07-23 09:19
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0097_auto_20180722_0804'),
('paypal', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='referencedpaypalobject',
name='payment',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.OrderPayment'),
),
]

View File

@@ -4,3 +4,4 @@ from django.db import models
class ReferencedPayPalObject(models.Model):
reference = models.CharField(max_length=190, db_index=True, unique=True)
order = models.ForeignKey('pretixbase.Order')
payment = models.ForeignKey('pretixbase.OrderPayment', null=True, blank=True)

View File

@@ -7,14 +7,14 @@ import paypalrestsdk
from django import forms
from django.contrib import messages
from django.core import signing
from django.http import HttpRequest
from django.template.loader import get_template
from django.utils.translation import ugettext as __, ugettext_lazy as _
from pretix.base.decimal import round_decimal
from pretix.base.models import Order, Quota, RequiredAction
from pretix.base.models import OrderPayment, OrderRefund, Quota
from pretix.base.payment import BasePaymentProvider, PaymentException
from pretix.base.services.mail import SendMailException
from pretix.base.services.orders import mark_order_paid, mark_order_refunded
from pretix.helpers.urls import build_absolute_uri as build_global_uri
from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.plugins.paypal.models import ReferencedPayPalObject
@@ -22,18 +22,6 @@ from pretix.plugins.paypal.models import ReferencedPayPalObject
logger = logging.getLogger('pretix.plugins.paypal')
class RefundForm(forms.Form):
auto_refund = forms.ChoiceField(
initial='auto',
label=_('Refund automatically?'),
choices=(
('auto', _('Automatically refund charge with PayPal')),
('manual', _('Do not send refund instruction to PayPal, only mark as refunded in pretix'))
),
widget=forms.RadioSelect,
)
class Paypal(BasePaymentProvider):
identifier = 'paypal'
verbose_name = _('PayPal')
@@ -162,6 +150,10 @@ class Paypal(BasePaymentProvider):
'XPF': 0,
}))
@property
def abort_pending_allowed(self):
return False
def _create_payment(self, request, payment):
try:
if payment.create():
@@ -196,36 +188,23 @@ class Paypal(BasePaymentProvider):
ctx = {'request': request, 'event': self.event, 'settings': self.settings}
return template.render(ctx)
def payment_perform(self, request, order) -> str:
"""
Will be called if the user submitted his order successfully to initiate the
payment process.
It should return a custom redirct URL, if you need special behavior, or None to
continue with default behavior.
On errors, it should use Django's message framework to display an error message
to the user (or the normal form validation error messages).
:param order: The order object
"""
if (request.session.get('payment_paypal_id', '') == ''
or request.session.get('payment_paypal_payer', '') == ''):
def execute_payment(self, request: HttpRequest, payment: OrderPayment):
if (request.session.get('payment_paypal_id', '') == '' or request.session.get('payment_paypal_payer', '') == ''):
raise PaymentException(_('We were unable to process your payment. See below for details on how to '
'proceed.'))
self.init_api()
payment = paypalrestsdk.Payment.find(request.session.get('payment_paypal_id'))
ReferencedPayPalObject.objects.get_or_create(order=order, reference=payment.id)
if str(payment.transactions[0].amount.total) != str(order.total) or payment.transactions[0].amount.currency != \
self.event.currency:
logger.error('Value mismatch: Order %s vs payment %s' % (order.id, str(payment)))
pp_payment = paypalrestsdk.Payment.find(request.session.get('payment_paypal_id'))
ReferencedPayPalObject.objects.get_or_create(order=payment.order, payment=payment, reference=pp_payment.id)
if str(pp_payment.transactions[0].amount.total) != str(payment.amount) or pp_payment.transactions[0].amount.currency \
!= self.event.currency:
logger.error('Value mismatch: Payment %s vs paypal trans %s' % (payment.id, str(pp_payment)))
raise PaymentException(_('We were unable to process your payment. See below for details on how to '
'proceed.'))
return self._execute_payment(payment, request, order)
return self._execute_payment(pp_payment, request, payment)
def _execute_payment(self, payment, request, order):
def _execute_payment(self, payment, request, payment_obj):
if payment.state == 'created':
payment.replace([
{
@@ -234,10 +213,11 @@ class Paypal(BasePaymentProvider):
"value": {
"items": [
{
"name": __('Order {slug}-{code}').format(slug=self.event.slug.upper(), code=order.code),
"name": __('Order {slug}-{code}').format(slug=self.event.slug.upper(),
code=payment_obj.order.code),
"quantity": 1,
"price": self.format_price(order.total),
"currency": order.event.currency
"price": self.format_price(payment_obj.amount),
"currency": payment_obj.order.event.currency
}
]
}
@@ -247,122 +227,92 @@ class Paypal(BasePaymentProvider):
"path": "/transactions/0/description",
"value": __('Order {order} for {event}').format(
event=request.event.name,
order=order.code
order=payment_obj.order.code
)
}
])
payment.execute({"payer_id": request.session.get('payment_paypal_payer')})
order.refresh_from_db()
payment_obj.refresh_from_db()
if payment.state == 'pending':
messages.warning(request, _('PayPal has not yet approved the payment. We will inform you as soon as the '
'payment completed.'))
order.payment_info = json.dumps(payment.to_dict())
order.save()
payment_obj.info = json.dumps(payment.to_dict())
payment_obj.state = OrderPayment.PAYMENT_STATE_PENDING
payment_obj.save()
return
if payment.state != 'approved':
payment_obj.state = OrderPayment.PAYMENT_STATE_FAILED
payment_obj.save()
logger.error('Invalid state: %s' % str(payment))
raise PaymentException(_('We were unable to process your payment. See below for details on how to '
'proceed.'))
if order.status == Order.STATUS_PAID:
if payment_obj.state == OrderPayment.PAYMENT_STATE_CONFIRMED:
logger.warning('PayPal success event even though order is already marked as paid')
return
try:
mark_order_paid(order, 'paypal', json.dumps(payment.to_dict()))
payment_obj.info = json.dumps(payment.to_dict())
payment_obj.save(update_fields=['info'])
payment_obj.confirm()
except Quota.QuotaExceededException as e:
RequiredAction.objects.create(
event=request.event, action_type='pretix.plugins.paypal.overpaid', data=json.dumps({
'order': order.code,
'payment': payment.id
})
)
raise PaymentException(str(e))
except SendMailException:
messages.warning(request, _('There was an error sending the confirmation mail.'))
return None
def order_pending_render(self, request, order) -> str:
def payment_pending_render(self, request, payment) -> str:
retry = True
try:
if order.payment_info and json.loads(order.payment_info)['state'] == 'pending':
if payment.info and payment.info_data['state'] == 'pending':
retry = False
except KeyError:
pass
template = get_template('pretixplugins/paypal/pending.html')
ctx = {'request': request, 'event': self.event, 'settings': self.settings,
'retry': retry, 'order': order}
'retry': retry, 'order': payment.order}
return template.render(ctx)
def order_control_render(self, request, order) -> str:
if order.payment_info:
payment_info = json.loads(order.payment_info)
else:
payment_info = None
def payment_control_render(self, request: HttpRequest, payment: OrderPayment):
template = get_template('pretixplugins/paypal/control.html')
ctx = {'request': request, 'event': self.event, 'settings': self.settings,
'payment_info': payment_info, 'order': order}
'payment_info': payment.info_data, 'order': payment.order}
return template.render(ctx)
def _refund_form(self, request):
return RefundForm(data=request.POST if request.method == "POST" else None)
def payment_partial_refund_supported(self, payment: OrderPayment):
return True
def order_control_refund_render(self, order, request) -> str:
template = get_template('pretixplugins/paypal/control_refund.html')
ctx = {
'request': request,
'form': self._refund_form(request),
}
return template.render(ctx)
def order_control_refund_perform(self, request, order) -> "bool|str":
f = self._refund_form(request)
if not f.is_valid():
messages.error(request, _('Your input was invalid, please try again.'))
return
elif f.cleaned_data.get('auto_refund') == 'manual':
order = mark_order_refunded(order, user=request.user)
order.payment_manual = True
order.save()
return
def payment_refund_supported(self, payment: OrderPayment):
return True
def execute_refund(self, refund: OrderRefund):
self.init_api()
if order.payment_info:
payment_info = json.loads(order.payment_info)
else:
payment_info = None
if not payment_info:
mark_order_refunded(order, user=request.user)
messages.warning(request, _('We were unable to transfer the money back automatically. '
'Please get in touch with the customer and transfer it back manually.'))
return
for res in payment_info['transactions'][0]['related_resources']:
sale = None
for res in refund.payment.info_data['transactions'][0]['related_resources']:
for k, v in res.items():
if k == 'sale':
sale = paypalrestsdk.Sale.find(v['id'])
break
refund = sale.refund({})
if not refund.success():
mark_order_refunded(order, user=request.user)
messages.warning(request, _('We were unable to transfer the money back automatically. '
'Please get in touch with the customer and transfer it back manually.'))
pp_refund = sale.refund({
"amount": {
"total": self.format_price(refund.amount),
"currency": refund.order.event.currency
}
})
if not pp_refund.success():
raise PaymentException(_('Refunding the amount via PayPal failed: {}').format(refund.error))
else:
sale = paypalrestsdk.Payment.find(payment_info['id'])
order = mark_order_refunded(order, user=request.user)
order.payment_info = json.dumps(sale.to_dict())
order.save()
sale = paypalrestsdk.Payment.find(refund.payment.info_data['id'])
refund.payment.info = json.dumps(sale.to_dict())
refund.info = json.dumps(pp_refund.to_dict())
refund.done()
def order_can_retry(self, order):
return self._is_still_available(order=order)
def order_prepare(self, request, order):
def payment_prepare(self, request, payment_obj):
self.init_api()
payment = paypalrestsdk.Payment({
'intent': 'sale',
@@ -378,43 +328,58 @@ class Paypal(BasePaymentProvider):
"item_list": {
"items": [
{
"name": __('Order {slug}-{code}').format(slug=self.event.slug.upper(), code=order.code),
"name": __('Order {slug}-{code}').format(slug=self.event.slug.upper(),
code=payment_obj.order.code),
"quantity": 1,
"price": self.format_price(order.total),
"currency": order.event.currency
"price": self.format_price(payment_obj.amount),
"currency": payment_obj.order.event.currency
}
]
},
"amount": {
"currency": request.event.currency,
"total": self.format_price(order.total)
"total": self.format_price(payment_obj.amount)
},
"description": __('Order {order} for {event}').format(
event=request.event.name,
order=order.code
order=payment_obj.order.code
)
}
]
})
request.session['payment_paypal_order'] = order.pk
request.session['payment_paypal_order'] = payment_obj.order.pk
request.session['payment_paypal_payment'] = payment_obj.pk
return self._create_payment(request, payment)
def shred_payment_info(self, order: Order):
d = json.loads(order.payment_info)
new = {
'id': d.get('id'),
'payer': {
'payer_info': {
'email': ''
def shred_payment_info(self, obj):
if obj.info:
d = json.loads(obj.info)
new = {
'id': d.get('id'),
'payer': {
'payer_info': {
'email': ''
}
},
'update_time': d.get('update_time'),
'transactions': [
{
'amount': t.get('amount')
} for t in d.get('transactions', [])
],
'_shredded': True
}
obj.info = json.dumps(new)
obj.save(update_fields=['info'])
for le in obj.order.all_logentries().filter(action_type="pretix.plugins.paypal.event").exclude(data=""):
d = le.parsed_data
if 'resource' in d:
d['resource'] = {
'id': d['resource'].get('id'),
'sale_id': d['resource'].get('sale_id'),
'parent_payment': d['resource'].get('parent_payment'),
}
},
'update_time': d.get('update_time'),
'transactions': [
{
'amount': t.get('amount')
} for t in d.get('transactions', [])
],
'_shredded': True
}
order.payment_info = json.dumps(new)
order.save(update_fields=['payment_info'])
le.data = json.dumps(d)
le.shredded = True
le.save(update_fields=['data', 'shredded'])

View File

@@ -4,10 +4,8 @@ from django.dispatch import receiver
from django.template.loader import get_template
from django.utils.translation import ugettext_lazy as _
from pretix.base.shredder import BaseDataShredder
from pretix.base.signals import (
logentry_display, register_data_shredders, register_payment_providers,
requiredaction_display,
logentry_display, register_payment_providers, requiredaction_display,
)
@@ -55,32 +53,3 @@ def pretixcontrol_action_display(sender, action, request, **kwargs):
ctx = {'data': data, 'event': sender, 'action': action}
return template.render(ctx, request)
class PaymentLogsShredder(BaseDataShredder):
verbose_name = _('PayPal payment history')
identifier = 'paypal_logs'
description = _('This will remove payment-related history information. No download will be offered.')
def generate_files(self):
pass
def shred_data(self):
for le in self.event.logentry_set.filter(action_type="pretix.plugins.paypal.event").exclude(data=""):
d = le.parsed_data
if 'resource' in d:
d['resource'] = {
'id': d['resource'].get('id'),
'sale_id': d['resource'].get('sale_id'),
'parent_payment': d['resource'].get('parent_payment'),
}
le.data = json.dumps(d)
le.shredded = True
le.save(update_fields=['data', 'shredded'])
@receiver(register_data_shredders, dispatch_uid="paypal_shredders")
def register_shredder(sender, **kwargs):
return [
PaymentLogsShredder,
]

View File

@@ -7,14 +7,3 @@
Do you want to mark the matching order ({{ order }}) as refunded?
{% endblocktrans %}
</p>
<form class="form-inline" method="post" action="{% url "plugins:paypal:refund" event=event.slug organizer=event.organizer.slug id=action.id %}">
{% csrf_token %}
<a href="{% url "control:event.requiredaction.discard" event=event.slug organizer=event.organizer.slug id=action.id %}"
class="btn btn-default">
{% trans "No" %}
</a>
<button type="submit" class="btn btn-default btn-danger">
{% trans "Yes, mark order as refunded" %}
</button>&nbsp;
{% trans "This action cannot be undone." %}
</form>

View File

@@ -1,19 +1,6 @@
{% load i18n %}
{% if payment_info %}
{% if order.status == "p" %}
<p>{% blocktrans trimmed %}
This order has been paid via PayPal.
{% endblocktrans %}</p>
{% elif order.status == "r" %}
<p>{% blocktrans trimmed %}
This order has been planned to be paid via PayPal and has been marked as refunded.
{% endblocktrans %}</p>
{% else %}
<p>{% blocktrans trimmed %}
This order has been planned to be paid via PayPal, but the payment has not yet been completed.
{% endblocktrans %}</p>
{% endif %}
<dl class="dl-horizontal">
<dt>{% trans "Payment ID" %}</dt>
<dd>{{ payment_info.id }}</dd>
@@ -26,8 +13,4 @@
<dt>{% trans "Currency" %}</dt>
<dd>{{ payment_info.transactions.0.amount.currency }}</dd>
</dl>
{% else %}
<p>{% blocktrans trimmed %}
This order has been planned to be paid via PayPal, but the payment has not yet been completed.
{% endblocktrans %}</p>
{% endif %}

View File

@@ -1,2 +0,0 @@
{% load bootstrap3 %}
{% bootstrap_form form %}

View File

@@ -2,7 +2,7 @@ from django.conf.urls import include, url
from pretix.multidomain import event_url
from .views import abort, redirect_view, refund, success, webhook
from .views import abort, redirect_view, success, webhook
event_patterns = [
url(r'^paypal/', include([
@@ -19,7 +19,5 @@ event_patterns = [
urlpatterns = [
url(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/paypal/refund/(?P<id>\d+)/',
refund, name='refund'),
url(r'^_paypal/webhook/$', webhook, name='webhook'),
]

View File

@@ -1,22 +1,20 @@
import json
import logging
from decimal import Decimal
import paypalrestsdk
from django.contrib import messages
from django.core import signing
from django.db import transaction
from django.db.models import Sum
from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.shortcuts import redirect, render
from django.utils.translation import ugettext_lazy as _
from django.views.decorators.clickjacking import xframe_options_exempt
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from pretix.base.models import Order, Quota, RequiredAction
from pretix.base.models import Order, OrderPayment, OrderRefund, Quota
from pretix.base.payment import PaymentException
from pretix.base.services.orders import mark_order_paid, mark_order_refunded
from pretix.control.permissions import event_permission_required
from pretix.multidomain.urlreverse import eventreverse
from pretix.plugins.paypal.models import ReferencedPayPalObject
from pretix.plugins.paypal.payment import Paypal
@@ -50,16 +48,16 @@ def success(request, *args, **kwargs):
if 'cart_namespace' in kwargs:
urlkwargs['cart_namespace'] = kwargs['cart_namespace']
if request.session.get('payment_paypal_order'):
order = Order.objects.get(pk=request.session.get('payment_paypal_order'))
if request.session.get('payment_paypal_payment'):
payment = OrderPayment.objects.get(pk=request.session.get('payment_paypal_payment'))
else:
order = None
payment = None
if pid == request.session.get('payment_paypal_id', None):
if order:
if payment:
prov = Paypal(request.event)
try:
resp = prov.payment_perform(request, order)
resp = prov.execute_payment(request, payment)
except PaymentException as e:
messages.error(request, str(e))
urlkwargs['step'] = 'payment'
@@ -72,11 +70,11 @@ def success(request, *args, **kwargs):
urlkwargs['step'] = 'payment'
return redirect(eventreverse(request.event, 'presale:event.checkout', kwargs=urlkwargs))
if order:
if payment:
return redirect(eventreverse(request.event, 'presale:event.order', kwargs={
'order': order.code,
'secret': order.secret
}) + ('?paid=yes' if order.status == Order.STATUS_PAID else ''))
'order': payment.order.code,
'secret': payment.order.secret
}) + ('?paid=yes' if payment.order.status == Order.STATUS_PAID else ''))
else:
urlkwargs['step'] = 'confirm'
return redirect(eventreverse(request.event, 'presale:event.checkout', kwargs=urlkwargs))
@@ -85,16 +83,16 @@ def success(request, *args, **kwargs):
def abort(request, *args, **kwargs):
messages.error(request, _('It looks like you canceled the PayPal payment'))
if request.session.get('payment_paypal_order'):
order = Order.objects.get(pk=request.session.get('payment_paypal_order'))
if request.session.get('payment_paypal_payment'):
payment = OrderPayment.objects.get(pk=request.session.get('payment_paypal_payment'))
else:
order = None
payment = None
if order:
if payment:
return redirect(eventreverse(request.event, 'presale:event.order', kwargs={
'order': order.code,
'secret': order.secret
}) + ('?paid=yes' if order.status == Order.STATUS_PAID else ''))
'order': payment.order.code,
'secret': payment.order.secret
}) + ('?paid=yes' if payment.order.status == Order.STATUS_PAID else ''))
else:
return redirect(eventreverse(request.event, 'presale:event.checkout', kwargs={'step': 'payment'}))
@@ -124,6 +122,7 @@ def webhook(request, *args, **kwargs):
)
event = rso.order.event
except ReferencedPayPalObject.DoesNotExist:
rso = None
if hasattr(request, 'event'):
event = request.event
else:
@@ -138,74 +137,67 @@ def webhook(request, *args, **kwargs):
logger.exception('PayPal error on webhook. Event data: %s' % str(event_json))
return HttpResponse('Sale not found', status=500)
orders = Order.objects.filter(event=event, payment_provider='paypal',
payment_info__icontains=sale['id'])
order = None
for o in orders:
payment_info = json.loads(o.payment_info)
for res in payment_info['transactions'][0]['related_resources']:
for k, v in res.items():
if k == 'sale' and v['id'] == sale['id']:
order = o
break
if rso and rso.payment:
payment = rso.payment
else:
payments = OrderPayment.objects.filter(order__event=event, provider='paypal',
info__icontains=sale['id'])
payment = None
for p in payments:
payment_info = p.info_data
for res in payment_info['transactions'][0]['related_resources']:
for k, v in res.items():
if k == 'sale' and v['id'] == sale['id']:
payment = p
break
if not order:
return HttpResponse('Order not found', status=200)
if not payment:
return HttpResponse('Payment not found', status=200)
order.log_action('pretix.plugins.paypal.event', data=event_json)
payment.order.log_action('pretix.plugins.paypal.event', data=event_json)
if order.status == Order.STATUS_PAID and sale['state'] in ('partially_refunded', 'refunded'):
RequiredAction.objects.create(
event=event, action_type='pretix.plugins.paypal.refund', data=json.dumps({
'order': order.code,
'sale': sale['id']
})
)
elif order.status not in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and sale['state'] == 'completed' and \
order.payment_provider != "paypal":
RequiredAction.objects.create(
event=event, action_type='pretix.plugins.paypal.double', data=json.dumps({
'order': order.code,
'payment': sale['parent_payment']
})
)
elif order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and sale['state'] == 'completed':
try:
mark_order_paid(order, user=None)
except Quota.QuotaExceededException:
if not RequiredAction.objects.filter(event=event, action_type='pretix.plugins.paypal.overpaid',
data__icontains=order.code).exists():
RequiredAction.objects.create(
event=event, action_type='pretix.plugins.paypal.overpaid', data=json.dumps({
'order': order.code,
'payment': sale['parent_payment']
})
if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED and sale['state'] in ('partially_refunded', 'refunded'):
if event_json['resource_type'] == 'refund':
try:
refund = paypalrestsdk.Refund.find(event_json['resource']['id'])
except:
logger.exception('PayPal error on webhook. Event data: %s' % str(event_json))
return HttpResponse('Refund not found', status=500)
known_refunds = {r.info_data.get('id'): r for r in payment.refunds.all()}
if refund['id'] not in known_refunds:
payment.create_external_refund(
amount=abs(Decimal(refund['amount']['total'])),
info=json.dumps(refund.to_dict() if not isinstance(refund, dict) else refund)
)
elif known_refunds.get(refund['id']).state in (
OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT) and refund['state'] == 'completed':
known_refunds.get(refund['id']).done()
if 'total_refunded_amount' in refund:
known_sum = payment.refunds.filter(
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_SOURCE_EXTERNAL)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
total_refunded_amount = Decimal(refund['total_refunded_amount']['value'])
if known_sum < total_refunded_amount:
payment.create_external_refund(
amount=total_refunded_amount - known_sum
)
elif sale['state'] == 'refunded':
known_sum = payment.refunds.filter(
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_SOURCE_EXTERNAL)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
if known_sum < payment.amount:
payment.create_external_refund(
amount=payment.amount - known_sum
)
elif payment.state in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED) and sale['state'] == 'completed':
try:
payment.confirm()
except Quota.QuotaExceededException:
pass
return HttpResponse(status=200)
@event_permission_required('can_change_orders')
@require_POST
def refund(request, **kwargs):
with transaction.atomic():
action = get_object_or_404(RequiredAction, event=request.event, pk=kwargs.get('id'),
action_type='pretix.plugins.paypal.refund', done=False)
data = json.loads(action.data)
action.done = True
action.user = request.user
action.save()
order = get_object_or_404(Order, event=request.event, code=data['order'])
if order.status != Order.STATUS_PAID:
messages.error(request, _('The order cannot be marked as refunded as it is not marked as paid!'))
else:
mark_order_refunded(order, user=request.user)
messages.success(
request, _('The order has been marked as refunded and the issue has been marked as resolved!')
)
return redirect(reverse('control:event.order', kwargs={
'organizer': request.event.organizer.slug,
'event': request.event.slug,
'code': data['order']
}))

View File

@@ -18,6 +18,13 @@
</div>
<div class="panel-body">
<div id="obd_chart" class="chart"></div>
<p class="help-block">
<small>
{% blocktrans trimmed %}
Orders paid in multiple payments are shown with the date of their last payment.
{% endblocktrans %}
</small>
</p>
</div>
</div>
<div class="panel panel-default">
@@ -34,6 +41,14 @@
{% endblocktrans %}
</div>
{% endif %}
<p class="help-block">
<small>
{% blocktrans trimmed %}
Only fully paid orders are counted.
Orders paid in multiple payments are shown with the date of their last payment.
{% endblocktrans %}
</small>
</p>
</div>
</div>
<div class="panel panel-default">

View File

@@ -3,11 +3,13 @@ import json
import dateutil.parser
import dateutil.rrule
from django.db.models import Count
from django.db.models import Count, DateTimeField, Max, OuterRef, Subquery
from django.utils import timezone
from django.views.generic import TemplateView
from pretix.base.models import Item, Order, OrderPosition, SubEvent
from pretix.base.models import (
Item, Order, OrderPayment, OrderPosition, SubEvent,
)
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.control.views import ChartContainingView
from pretix.plugins.statistics.signals import clear_cache
@@ -35,10 +37,29 @@ class IndexView(EventPermissionRequiredMixin, ChartContainingView, TemplateView)
cache = self.request.event.cache
ckey = str(subevent.pk) if subevent else 'all'
p_date = OrderPayment.objects.filter(
order=OuterRef('pk'),
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
payment_date__isnull=False
).order_by().values('order').annotate(
m=Max('payment_date')
).values(
'm'
)
op_date = OrderPayment.objects.filter(
order=OuterRef('order'),
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
payment_date__isnull=False
).order_by().values('order').annotate(
m=Max('payment_date')
).values(
'm'
)
# Orders by day
ctx['obd_data'] = cache.get('statistics_obd_data' + ckey)
if not ctx['obd_data']:
oqs = Order.objects
oqs = Order.objects.annotate(payment_date=Subquery(p_date, output_field=DateTimeField()))
if subevent:
oqs = oqs.filter(positions__subevent_id=subevent).distinct()
@@ -106,16 +127,20 @@ class IndexView(EventPermissionRequiredMixin, ChartContainingView, TemplateView)
if not ctx['rev_data']:
rev_by_day = {}
if subevent:
for o in OrderPosition.objects.filter(order__event=self.request.event,
subevent=subevent,
order__status=Order.STATUS_PAID,
order__payment_date__isnull=False).values('order__payment_date', 'price'):
for o in OrderPosition.objects.annotate(
payment_date=Subquery(op_date, output_field=DateTimeField())
).filter(order__event=self.request.event,
subevent=subevent,
order__status=Order.STATUS_PAID,
order__payment_date__isnull=False).values('order__payment_date', 'price'):
day = o['order__payment_date'].astimezone(tz).date()
rev_by_day[day] = rev_by_day.get(day, 0) + o['price']
else:
for o in Order.objects.filter(event=self.request.event,
status=Order.STATUS_PAID,
payment_date__isnull=False).values('payment_date', 'total'):
for o in Order.objects.annotate(
payment_date=Subquery(p_date, output_field=DateTimeField())
).filter(event=self.request.event,
status=Order.STATUS_PAID,
payment_date__isnull=False).values('payment_date', 'total'):
day = o['payment_date'].astimezone(tz).date()
rev_by_day[day] = rev_by_day.get(day, 0) + o['total']

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.13 on 2018-07-22 08:01
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0096_auto_20180722_0801'),
('stripe', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='referencedstripeobject',
name='payment',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.OrderPayment'),
),
]

View File

@@ -4,3 +4,4 @@ from django.db import models
class ReferencedStripeObject(models.Model):
reference = models.CharField(max_length=190, db_index=True, unique=True)
order = models.ForeignKey('pretixbase.Order')
payment = models.ForeignKey('pretixbase.OrderPayment', null=True, blank=True)

View File

@@ -9,6 +9,7 @@ from django import forms
from django.conf import settings
from django.contrib import messages
from django.core import signing
from django.http import HttpRequest
from django.template.loader import get_template
from django.urls import reverse
from django.utils.crypto import get_random_string
@@ -16,10 +17,10 @@ from django.utils.http import urlquote
from django.utils.translation import pgettext, ugettext, ugettext_lazy as _
from pretix import __version__
from pretix.base.models import Event, Order, Quota, RequiredAction
from pretix.base.decimal import round_decimal
from pretix.base.models import Event, OrderPayment, OrderRefund, Quota
from pretix.base.payment import BasePaymentProvider, PaymentException
from pretix.base.services.mail import SendMailException
from pretix.base.services.orders import mark_order_paid, mark_order_refunded
from pretix.base.settings import SettingsSandbox
from pretix.helpers.urls import build_absolute_uri as build_global_uri
from pretix.multidomain.urlreverse import build_absolute_uri
@@ -29,18 +30,6 @@ from pretix.plugins.stripe.models import ReferencedStripeObject
logger = logging.getLogger('pretix.plugins.stripe')
class RefundForm(forms.Form):
auto_refund = forms.ChoiceField(
initial='auto',
label=_('Refund automatically?'),
choices=(
('auto', _('Automatically refund charge with Stripe')),
('manual', _('Do not send refund instruction to Stripe, only mark as refunded in pretix'))
),
widget=forms.RadioSelect,
)
class StripeSettingsHolder(BasePaymentProvider):
identifier = 'stripe_settings'
verbose_name = _('Stripe')
@@ -233,12 +222,22 @@ class StripeMethod(BasePaymentProvider):
return self.settings.get('_enabled', as_type=bool) and self.settings.get('method_{}'.format(self.method),
as_type=bool)
def order_prepare(self, request, order):
def payment_refund_supported(self, payment: OrderPayment) -> bool:
return True
def payment_partial_refund_supported(self, payment: OrderPayment) -> bool:
return True
def payment_prepare(self, request, payment):
return self.checkout_prepare(request, None)
def _get_amount(self, order):
def _amount_to_decimal(self, cents):
places = settings.CURRENCY_PLACES.get(self.event.currency, 2)
return int(order.total * 10 ** places)
return round_decimal(float(cents) / (10 ** places), self.event.currency)
def _get_amount(self, payment):
places = settings.CURRENCY_PLACES.get(self.event.currency, 2)
return int(payment.amount * 10 ** places)
@property
def api_kwargs(self):
@@ -268,29 +267,29 @@ class StripeMethod(BasePaymentProvider):
ctx = {'request': request, 'event': self.event, 'settings': self.settings, 'provider': self}
return template.render(ctx)
def order_can_retry(self, order):
return self._is_still_available(order=order)
def payment_can_retry(self, payment):
return self._is_still_available(order=payment.order)
def _charge_source(self, request, source, order):
def _charge_source(self, request, source, payment):
try:
params = {}
if not source.startswith('src_'):
params['statement_descriptor'] = ugettext('{event}-{code}').format(
event=self.event.slug.upper(),
code=order.code
code=payment.order.code
)[:22]
params.update(self.api_kwargs)
charge = stripe.Charge.create(
amount=self._get_amount(order),
amount=self._get_amount(payment),
currency=self.event.currency.lower(),
source=source,
metadata={
'order': str(order.id),
'order': str(payment.order.id),
'event': self.event.id,
'code': order.code
'code': payment.order.code
},
# TODO: Is this sufficient?
idempotency_key=str(self.event.id) + order.code + source,
idempotency_key=str(self.event.id) + payment.order.code + source,
**params
)
except stripe.error.CardError as e:
@@ -301,11 +300,12 @@ class StripeMethod(BasePaymentProvider):
err = {'message': str(e)}
logger.exception('Stripe error: %s' % str(e))
logger.info('Stripe card error: %s' % str(err))
order.payment_info = json.dumps({
payment.info_data = {
'error': True,
'message': err['message'],
})
order.save(update_fields=['payment_info'])
}
payment.state = OrderPayment.PAYMENT_STATE_FAILED
payment.save()
raise PaymentException(_('Stripe reported an error with your card: %s') % err['message'])
except stripe.error.StripeError as e:
@@ -315,25 +315,24 @@ class StripeMethod(BasePaymentProvider):
else:
err = {'message': str(e)}
logger.exception('Stripe error: %s' % str(e))
order.payment_info = json.dumps({
payment.info_data = {
'error': True,
'message': err['message'],
})
order.save(update_fields=['payment_info'])
}
payment.state = OrderPayment.PAYMENT_STATE_FAILED
payment.save()
raise PaymentException(_('We had trouble communicating with Stripe. Please try again and get in touch '
'with us if this problem persists.'))
else:
ReferencedStripeObject.objects.get_or_create(order=order, reference=charge.id)
ReferencedStripeObject.objects.get_or_create(
reference=charge.id,
defaults={'order': payment.order, 'payment': payment}
)
if charge.status == 'succeeded' and charge.paid:
try:
mark_order_paid(order, self.identifier, str(charge))
payment.info = str(charge)
payment.confirm()
except Quota.QuotaExceededException as e:
RequiredAction.objects.create(
event=self.event, action_type='pretix.plugins.stripe.overpaid', data=json.dumps({
'order': order.code,
'charge': charge.id
})
)
raise PaymentException(str(e))
except SendMailException:
@@ -342,18 +341,20 @@ class StripeMethod(BasePaymentProvider):
if request:
messages.warning(request, _('Your payment is pending completion. We will inform you as soon as the '
'payment completed.'))
order.payment_info = str(charge)
order.save(update_fields=['payment_info'])
payment.info = str(charge)
payment.state = OrderPayment.PAYMENT_STATE_PENDING
payment.save()
return
else:
logger.info('Charge failed: %s' % str(charge))
order.payment_info = str(charge)
order.save(update_fields=['payment_info'])
payment.info = str(charge)
payment.state = OrderPayment.PAYMENT_STATE_FAILED
payment.save()
raise PaymentException(_('Stripe reported an error: %s') % charge.failure_message)
def order_pending_render(self, request, order) -> str:
if order.payment_info:
payment_info = json.loads(order.payment_info)
def payment_pending_render(self, request, payment) -> str:
if payment.info:
payment_info = json.loads(payment.info)
else:
payment_info = None
template = get_template('pretixplugins/stripe/pending.html')
@@ -362,14 +363,15 @@ class StripeMethod(BasePaymentProvider):
'event': self.event,
'settings': self.settings,
'provider': self,
'order': order,
'order': payment.order,
'payment': payment,
'payment_info': payment_info,
}
return template.render(ctx)
def order_control_render(self, request, order) -> str:
if order.payment_info:
payment_info = json.loads(order.payment_info)
def payment_control_render(self, request, payment) -> str:
if payment.info:
payment_info = json.loads(payment.info)
if 'amount' in payment_info:
payment_info['amount'] /= 10 ** settings.CURRENCY_PLACES.get(self.event.currency, 2)
else:
@@ -380,50 +382,25 @@ class StripeMethod(BasePaymentProvider):
'event': self.event,
'settings': self.settings,
'payment_info': payment_info,
'order': order,
'payment': payment,
'method': self.method,
'provider': self,
}
return template.render(ctx)
def _refund_form(self, request):
return RefundForm(data=request.POST if request.method == "POST" else None)
def order_control_refund_render(self, order, request) -> str:
template = get_template('pretixplugins/stripe/control_refund.html')
ctx = {
'request': request,
'form': self._refund_form(request),
}
return template.render(ctx)
def order_control_refund_perform(self, request, order) -> "bool|str":
def execute_refund(self, refund: OrderRefund):
self._init_api()
f = self._refund_form(request)
if not f.is_valid():
messages.error(request, _('Your input was invalid, please try again.'))
return
elif f.cleaned_data.get('auto_refund') == 'manual':
order = mark_order_refunded(order, user=request.user)
order.payment_manual = True
order.save()
return
if order.payment_info:
payment_info = json.loads(order.payment_info)
else:
payment_info = None
payment_info = refund.payment.info_data
if not payment_info:
mark_order_refunded(order, user=request.user)
messages.warning(request, _('We were unable to transfer the money back automatically. '
'Please get in touch with the customer and transfer it back manually.'))
return
raise PaymentException(_('No payment information found.'))
try:
ch = stripe.Charge.retrieve(payment_info['id'], **self.api_kwargs)
ch.refunds.create()
ch.refunds.create(
amount=self._get_amount(refund),
)
ch.refresh()
except (stripe.error.InvalidRequestError, stripe.error.AuthenticationError, stripe.error.APIConnectionError) \
as e:
@@ -433,22 +410,18 @@ class StripeMethod(BasePaymentProvider):
else:
err = {'message': str(e)}
logger.exception('Stripe error: %s' % str(e))
messages.error(request, _('We had trouble communicating with Stripe. Please try again and contact '
'support if the problem persists.'))
raise PaymentException(_('We had trouble communicating with Stripe. Please try again and contact '
'support if the problem persists.'))
except stripe.error.StripeError as err:
logger.error('Stripe error: %s' % str(err))
except stripe.error.StripeError:
mark_order_refunded(order, user=request.user)
messages.warning(request, _('We were unable to transfer the money back automatically. '
'Please get in touch with the customer and transfer it back manually.'))
raise PaymentException(_('Stripe returned an error'))
else:
order = mark_order_refunded(order, user=request.user)
order.payment_info = str(ch)
order.save()
refund.done()
def payment_perform(self, request, order) -> str:
def execute_payment(self, request: HttpRequest, payment: OrderPayment):
self._init_api()
try:
source = self._create_source(request, order)
source = self._create_source(request, payment)
except stripe.error.StripeError as e:
if e.json_body:
err = e.json_body['error']
@@ -456,18 +429,23 @@ class StripeMethod(BasePaymentProvider):
else:
err = {'message': str(e)}
logger.exception('Stripe error: %s' % str(e))
order.payment_info = json.dumps({
payment.info_data = {
'error': True,
'message': err['message'],
})
order.save(update_fields=['payment_info'])
}
payment.state = OrderPayment.PAYMENT_STATE_FAILED
payment.save()
raise PaymentException(_('We had trouble communicating with Stripe. Please try again and get in touch '
'with us if this problem persists.'))
ReferencedStripeObject.objects.get_or_create(order=order, reference=source.id)
order.payment_info = str(source)
order.save(update_fields=['payment_info'])
request.session['payment_stripe_order_secret'] = order.secret
ReferencedStripeObject.objects.get_or_create(
reference=source.id,
defaults={'order': payment.order, 'payment': payment}
)
payment.info = str(source)
payment.state = OrderPayment.PAYMENT_STATE_PENDING
payment.save()
request.session['payment_stripe_order_secret'] = payment.order.secret
return self.redirect(request, source.redirect.url)
def redirect(self, request, url):
@@ -480,10 +458,10 @@ class StripeMethod(BasePaymentProvider):
else:
return str(url)
def shred_payment_info(self, order: Order):
if not order.payment_info:
def shred_payment_info(self, obj: OrderPayment):
if not obj.info:
return
d = json.loads(order.payment_info)
d = json.loads(obj.info)
new = {}
if 'source' in d:
new['source'] = {
@@ -500,13 +478,30 @@ class StripeMethod(BasePaymentProvider):
'last4': d['source'].get('card', {}).get('last4'),
}
}
if 'amount' in d:
new['amount'] = d['amount']
if 'currency' in d:
new['currency'] = d['currency']
if 'status' in d:
new['status'] = d['status']
if 'id' in d:
new['id'] = d['id']
new['_shredded'] = True
order.payment_info = json.dumps(new)
order.save(update_fields=['payment_info'])
new['_shredded'] = True
obj.info = json.dumps(new)
obj.save(update_fields=['info'])
for le in obj.order.all_logentries().filter(
action_type="pretix.plugins.stripe.event"
).exclude(data="", shredded=True):
d = le.parsed_data
if 'data' in d:
for k, v in list(d['data']['object'].items()):
if v not in ('reason', 'status', 'failure_message', 'object', 'id'):
d['data']['object'][k] = ''
le.data = json.dumps(d)
le.shredded = True
le.save(update_fields=['data', 'shredded'])
class StripeCC(StripeMethod):
@@ -549,41 +544,47 @@ class StripeCC(StripeMethod):
else:
return card.three_d_secure == 'required'
def payment_perform(self, request, order) -> str:
def execute_payment(self, request: HttpRequest, payment: OrderPayment):
self._init_api()
if request.session['payment_stripe_token'].startswith('src_'):
try:
src = stripe.Source.retrieve(request.session['payment_stripe_token'], **self.api_kwargs)
if src.type == 'card' and src.card and self._use_3ds(src.card):
request.session['payment_stripe_order_secret'] = order.secret
request.session['payment_stripe_order_secret'] = payment.order.secret
source = stripe.Source.create(
type='three_d_secure',
amount=self._get_amount(order),
amount=self._get_amount(payment),
currency=self.event.currency.lower(),
three_d_secure={
'card': src.id
},
statement_descriptor=ugettext('{event}-{code}').format(
event=self.event.slug.upper(),
code=order.code
code=payment.order.code
)[:22],
metadata={
'order': str(order.id),
'order': str(payment.order.id),
'event': self.event.id,
'code': order.code
'code': payment.order.code
},
redirect={
'return_url': build_absolute_uri(self.event, 'plugins:stripe:return', kwargs={
'order': order.code,
'hash': hashlib.sha1(order.secret.lower().encode()).hexdigest(),
'order': payment.order.code,
'payment': payment.pk,
'hash': hashlib.sha1(payment.order.secret.lower().encode()).hexdigest(),
})
},
**self.api_kwargs
)
ReferencedStripeObject.objects.get_or_create(
reference=source.id,
defaults={'order': payment.order, 'payment': payment}
)
if source.status == "pending":
order.payment_info = str(source)
order.save(update_fields=['payment_info'])
payment.info = str(source)
payment.state = OrderPayment.PAYMENT_STATE_PENDING
payment.save()
return self.redirect(request, source.redirect.url)
except stripe.error.StripeError as e:
if e.json_body:
@@ -592,16 +593,17 @@ class StripeCC(StripeMethod):
else:
err = {'message': str(e)}
logger.exception('Stripe error: %s' % str(e))
order.payment_info = json.dumps({
payment.info_data = {
'error': True,
'message': err['message'],
})
order.save(update_fields=['payment_info'])
}
payment.state = OrderPayment.PAYMENT_STATE_FAILED
payment.save()
raise PaymentException(_('We had trouble communicating with Stripe. Please try again and get in touch '
'with us if this problem persists.'))
try:
self._charge_source(request, request.session['payment_stripe_token'], order)
self._charge_source(request, request.session['payment_stripe_token'], payment)
finally:
del request.session['payment_stripe_token']
@@ -628,16 +630,16 @@ class StripeGiropay(StripeMethod):
('account', forms.CharField(label=_('Account holder'))),
])
def _create_source(self, request, order):
def _create_source(self, request, payment):
try:
source = stripe.Source.create(
type='giropay',
amount=self._get_amount(order),
amount=self._get_amount(payment),
currency=self.event.currency.lower(),
metadata={
'order': str(order.id),
'order': str(payment.order.id),
'event': self.event.id,
'code': order.code
'code': payment.order.code
},
owner={
'name': request.session.get('payment_stripe_giropay_account') or ugettext('unknown name')
@@ -645,13 +647,14 @@ class StripeGiropay(StripeMethod):
giropay={
'statement_descriptor': ugettext('{event}-{code}').format(
event=self.event.slug.upper(),
code=order.code
code=payment.order.code
)[:35]
},
redirect={
'return_url': build_absolute_uri(self.event, 'plugins:stripe:return', kwargs={
'order': order.code,
'hash': hashlib.sha1(order.secret.lower().encode()).hexdigest(),
'order': payment.order.code,
'payment': payment.pk,
'hash': hashlib.sha1(payment.order.secret.lower().encode()).hexdigest(),
})
},
**self.api_kwargs
@@ -689,26 +692,27 @@ class StripeIdeal(StripeMethod):
}
return template.render(ctx)
def _create_source(self, request, order):
def _create_source(self, request, payment):
source = stripe.Source.create(
type='ideal',
amount=self._get_amount(order),
amount=self._get_amount(payment),
currency=self.event.currency.lower(),
metadata={
'order': str(order.id),
'order': str(payment.order.id),
'event': self.event.id,
'code': order.code
'code': payment.order.code
},
ideal={
'statement_descriptor': ugettext('{event}-{code}').format(
event=self.event.slug.upper(),
code=order.code
code=payment.order.code
)[:22]
},
redirect={
'return_url': build_absolute_uri(self.event, 'plugins:stripe:return', kwargs={
'order': order.code,
'hash': hashlib.sha1(order.secret.lower().encode()).hexdigest(),
'order': payment.order.code,
'payment': payment.pk,
'hash': hashlib.sha1(payment.order.secret.lower().encode()).hexdigest(),
})
},
**self.api_kwargs
@@ -737,20 +741,21 @@ class StripeAlipay(StripeMethod):
}
return template.render(ctx)
def _create_source(self, request, order):
def _create_source(self, request, payment):
source = stripe.Source.create(
type='alipay',
amount=self._get_amount(order),
amount=self._get_amount(payment),
currency=self.event.currency.lower(),
metadata={
'order': str(order.id),
'order': str(payment.order.id),
'event': self.event.id,
'code': order.code
'code': payment.order.code
},
redirect={
'return_url': build_absolute_uri(self.event, 'plugins:stripe:return', kwargs={
'order': order.code,
'hash': hashlib.sha1(order.secret.lower().encode()).hexdigest(),
'order': payment.order.code,
'payment': payment.pk,
'hash': hashlib.sha1(payment.order.secret.lower().encode()).hexdigest(),
})
},
**self.api_kwargs
@@ -786,16 +791,16 @@ class StripeBancontact(StripeMethod):
('account', forms.CharField(label=_('Account holder'), min_length=3)),
])
def _create_source(self, request, order):
def _create_source(self, request, payment):
try:
source = stripe.Source.create(
type='bancontact',
amount=self._get_amount(order),
amount=self._get_amount(payment),
currency=self.event.currency.lower(),
metadata={
'order': str(order.id),
'order': str(payment.order.id),
'event': self.event.id,
'code': order.code
'code': payment.order.code
},
owner={
'name': request.session.get('payment_stripe_bancontact_account') or ugettext('unknown name')
@@ -803,13 +808,14 @@ class StripeBancontact(StripeMethod):
bancontact={
'statement_descriptor': ugettext('{event}-{code}').format(
event=self.event.slug.upper(),
code=order.code
code=payment.order.code
)[:35]
},
redirect={
'return_url': build_absolute_uri(self.event, 'plugins:stripe:return', kwargs={
'order': order.code,
'hash': hashlib.sha1(order.secret.lower().encode()).hexdigest(),
'order': payment.order.code,
'payment': payment.pk,
'hash': hashlib.sha1(payment.order.secret.lower().encode()).hexdigest(),
})
},
**self.api_kwargs
@@ -860,27 +866,28 @@ class StripeSofort(StripeMethod):
))),
])
def _create_source(self, request, order):
def _create_source(self, request, payment):
source = stripe.Source.create(
type='sofort',
amount=self._get_amount(order),
amount=self._get_amount(payment),
currency=self.event.currency.lower(),
metadata={
'order': str(order.id),
'order': str(payment.order.id),
'event': self.event.id,
'code': order.code
'code': payment.order.code
},
sofort={
'country': request.session.get('payment_stripe_sofort_bank_country'),
'statement_descriptor': ugettext('{event}-{code}').format(
event=self.event.slug.upper(),
code=order.code
code=payment.order.code
)[:35]
},
redirect={
'return_url': build_absolute_uri(self.event, 'plugins:stripe:return', kwargs={
'order': order.code,
'hash': hashlib.sha1(order.secret.lower().encode()).hexdigest(),
'order': payment.order.code,
'payment': payment.pk,
'hash': hashlib.sha1(payment.order.secret.lower().encode()).hexdigest(),
})
},
**self.api_kwargs
@@ -899,12 +906,5 @@ class StripeSofort(StripeMethod):
return True
return False
def order_can_retry(self, order):
try:
if order.payment_info:
d = json.loads(order.payment_info)
if d.get('object') == 'charge' and d.get('status') == 'pending':
return False
except ValueError:
pass
return self._is_still_available(order=order)
def payment_can_retry(self, payment):
return payment.state != OrderPayment.PAYMENT_STATE_PENDING and self._is_still_available(order=payment.order)

View File

@@ -8,10 +8,9 @@ from django.template.loader import get_template
from django.utils.translation import ugettext_lazy as _
from pretix.base.settings import settings_hierarkey
from pretix.base.shredder import BaseDataShredder
from pretix.base.signals import (
logentry_display, register_data_shredders, register_global_settings,
register_payment_providers, requiredaction_display,
logentry_display, register_global_settings, register_payment_providers,
requiredaction_display,
)
from pretix.plugins.stripe.forms import StripeKeyValidator
from pretix.presale.signals import html_head
@@ -74,24 +73,6 @@ def pretixcontrol_logentry_display(sender, logentry, **kwargs):
return _('Stripe reported an event: {}').format(text)
@receiver(signal=requiredaction_display, dispatch_uid="stripe_requiredaction_display")
def pretixcontrol_action_display(sender, action, request, **kwargs):
if not action.action_type.startswith('pretix.plugins.stripe'):
return
data = json.loads(action.data)
if action.action_type == 'pretix.plugins.stripe.refund':
template = get_template('pretixplugins/stripe/action_refund.html')
elif action.action_type == 'pretix.plugins.stripe.overpaid':
template = get_template('pretixplugins/stripe/action_overpaid.html')
elif action.action_type == 'pretix.plugins.stripe.double':
template = get_template('pretixplugins/stripe/action_double.html')
ctx = {'data': data, 'event': sender, 'action': action}
return template.render(ctx, request)
settings_hierarkey.add_default('payment_stripe_method_cc', True, bool)
settings_hierarkey.add_default('payment_stripe_cc_3ds_mode', 'recommended', str)
@@ -137,28 +118,20 @@ def register_global_settings(sender, **kwargs):
])
class PaymentLogsShredder(BaseDataShredder):
verbose_name = _('Stripe payment history')
identifier = 'stripe_logs'
description = _('This will remove payment-related history information. No download will be offered.')
@receiver(signal=requiredaction_display, dispatch_uid="stripe_requiredaction_display")
def pretixcontrol_action_display(sender, action, request, **kwargs):
# DEPRECATED
if not action.action_type.startswith('pretix.plugins.stripe'):
return
def generate_files(self):
pass
data = json.loads(action.data)
def shred_data(self):
for le in self.event.logentry_set.filter(action_type="pretix.plugins.stripe.event").exclude(data=""):
d = le.parsed_data
if 'data' in d:
for k, v in list(d['data']['object'].items()):
if v not in ('reason', 'status', 'failure_message', 'object', 'id'):
d['data']['object'][k] = ''
le.data = json.dumps(d)
le.shredded = True
le.save(update_fields=['data', 'shredded'])
if action.action_type == 'pretix.plugins.stripe.refund':
template = get_template('pretixplugins/stripe/action_refund.html')
elif action.action_type == 'pretix.plugins.stripe.overpaid':
template = get_template('pretixplugins/stripe/action_overpaid.html')
elif action.action_type == 'pretix.plugins.stripe.double':
template = get_template('pretixplugins/stripe/action_double.html')
@receiver(register_data_shredders, dispatch_uid="stripe_shredders")
def register_shredder(sender, **kwargs):
return [
PaymentLogsShredder,
]
ctx = {'data': data, 'event': sender, 'action': action}
return template.render(ctx, request)

View File

@@ -7,14 +7,3 @@
Do you want to refund mark the matching order ({{ order }}) as refunded?
{% endblocktrans %}
</p>
<form class="form-inline" method="post" action="{% url "plugins:stripe:refund" event=event.slug organizer=event.organizer.slug id=action.id %}">
{% csrf_token %}
<a href="{% url "control:event.requiredaction.discard" event=event.slug organizer=event.organizer.slug id=action.id %}"
class="btn btn-default">
{% trans "No" %}
</a>
<button type="submit" class="btn btn-default btn-danger">
{% trans "Yes, mark order as refunded" %}
</button>&nbsp;
{% trans "This action cannot be undone." %}
</form>

View File

@@ -1,30 +1,19 @@
{% load i18n %}
{% if payment_info %}
{% if order.status == "p" %}
<p>{% blocktrans trimmed with method=provider.verbose_name %}
This order has been paid with {{ method }}.
{% endblocktrans %}</p>
{% elif order.status == "r" %}
<p>{% blocktrans trimmed with method=provider.verbose_name %}
This order has been planned to be paid with {{ method }} and has been marked as refunded.
{% endblocktrans %}</p>
{% else %}
<p>{% blocktrans trimmed with method=provider.verbose_name %}
This order has been planned to be paid with {{ method }}, but the payment has not yet been completed.
{% endblocktrans %}</p>
{% endif %}
{% if "source" in payment_info %}
<dl class="dl-horizontal">
<dt>{% trans "Charge ID" %}</dt>
<dd>{{ payment_info.id }}</dd>
{% if payment_info.source.type == "card" or payment_info.source.type == "three_d_secure" %}
{% if payment_info.source.card %}
<dt>{% trans "Card type" %}</dt>
<dd>{{ payment_info.source.brand }}</dd>
<dd>{{ payment_info.source.card.brand }}</dd>
<dt>{% trans "Card number" %}</dt>
<dd>**** **** **** {{ payment_info.source.last4 }}</dd>
<dt>{% trans "Payer name" %}</dt>
<dd>{{ payment_info.source.name }}</dd>
<dd>**** **** **** {{ payment_info.source.card.last4 }}</dd>
{% if payment_info.source.owner.name %}
<dt>{% trans "Payer name" %}</dt>
<dd>{{ payment_info.source.owner.name }}</dd>
{% endif %}
{% endif %}
{% if payment_info.source.type == "giropay" %}
<dt>{% trans "Bank" %}</dt>
@@ -58,8 +47,4 @@
<dd>{{ payment_info.message }}</dd>
</dl>
{% endif %}
{% else %}
<p>{% blocktrans trimmed %}
This order has been planned to be paid via Stripe, but the payment has not yet been completed.
{% endblocktrans %}</p>
{% endif %}

View File

@@ -1,2 +0,0 @@
{% load bootstrap3 %}
{% bootstrap_form form %}

View File

@@ -1,6 +1,6 @@
{% load i18n %}
{% if provider.method == "sofort" %}
{% if payment.state == "pending" %}
<p>{% blocktrans trimmed %}
We're waiting for an answer from the payment provider regarding your payment. Please contact us if this
takes more than a few days.

View File

@@ -3,20 +3,18 @@ from django.conf.urls import include, url
from pretix.multidomain import event_url
from .views import (
ReturnView, oauth_disconnect, oauth_return, redirect_view, refund, webhook,
ReturnView, oauth_disconnect, oauth_return, redirect_view, webhook,
)
event_patterns = [
url(r'^stripe/', include([
event_url(r'^webhook/$', webhook, name='webhook', require_live=False),
url(r'^redirect/$', redirect_view, name='redirect'),
url(r'^return/(?P<order>[^/]+)/(?P<hash>[^/]+)/$', ReturnView.as_view(), name='return'),
url(r'^return/(?P<order>[^/]+)/(?P<hash>[^/]+)/(?P<payment>[0-9]+)/$', ReturnView.as_view(), name='return'),
])),
]
urlpatterns = [
url(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/stripe/refund/(?P<id>\d+)/',
refund, name='refund'),
url(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/stripe/disconnect/',
oauth_disconnect, name='oauth.disconnect'),
url(r'^_stripe/webhook/$', webhook, name='webhook'),

View File

@@ -1,6 +0,0 @@
def refresh_order(order):
if not order.payment_provider.startswith('stripe_'):
raise ValueError("Not a stripe payment")
prov = order.event.get_payment_providers()[order.payment_provider]
prov._init_api()

View File

@@ -18,10 +18,9 @@ from django.views.decorators.clickjacking import xframe_options_exempt
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from pretix.base.models import Event, Order, Quota, RequiredAction
from pretix.base.models import Event, Order, OrderPayment, Quota
from pretix.base.payment import PaymentException
from pretix.base.services.locking import LockTimeoutException
from pretix.base.services.orders import mark_order_paid, mark_order_refunded
from pretix.base.settings import GlobalSettingsObject
from pretix.control.permissions import event_permission_required
from pretix.multidomain.urlreverse import eventreverse
@@ -105,8 +104,9 @@ def oauth_return(request, *args, **kwargs):
elif data['livemode'] and 'error' in testdata:
messages.error(request, _('Stripe returned an error: {}').format(testdata['error_description']))
else:
messages.success(request, _('Your Stripe account is now connected to pretix. You can change the settings in '
'detail below.'))
messages.success(request,
_('Your Stripe account is now connected to pretix. You can change the settings in '
'detail below.'))
event.settings.payment_stripe_publishable_key = data['stripe_publishable_key']
# event.settings.payment_stripe_connect_access_token = data['access_token'] we don't need it, right?
event.settings.payment_stripe_connect_refresh_token = data['refresh_token']
@@ -156,19 +156,30 @@ def webhook(request, *args, **kwargs):
try:
rso = ReferencedStripeObject.objects.select_related('order', 'order__event').get(reference=objid)
return func(rso.order.event, event_json, objid)
return func(rso.order.event, event_json, objid, rso)
except ReferencedStripeObject.DoesNotExist:
if hasattr(request, 'event'):
return func(request.event, event_json, objid)
return func(request.event, event_json, objid, None)
else:
return HttpResponse("Unable to detect event", status=200)
def charge_webhook(event, event_json, charge_id):
SOURCE_TYPES = {
'sofort': 'stripe_sofort',
'three_d_secure': 'stripe',
'card': 'stripe',
'giropay': 'stripe_giropay',
'ideal': 'stripe_ideal',
'alipay': 'stripe_alipay',
'bancontact': 'stripe_bancontact',
}
def charge_webhook(event, event_json, charge_id, rso):
prov = StripeCC(event)
prov._init_api()
try:
charge = stripe.Charge.retrieve(charge_id, **prov.api_kwargs)
charge = stripe.Charge.retrieve(charge_id, expand=['dispute'], **prov.api_kwargs)
except stripe.error.StripeError:
logger.exception('Stripe error on webhook. Event data: %s' % str(event_json))
return HttpResponse('Charge not found', status=500)
@@ -180,55 +191,71 @@ def charge_webhook(event, event_json, charge_id):
if int(metadata['event']) != event.pk:
return HttpResponse('Not interested in this event', status=200)
try:
order = event.orders.get(id=metadata['order'], payment_provider__startswith='stripe')
except Order.DoesNotExist:
return HttpResponse('Order not found', status=200)
if rso and rso.payment:
order = rso.payment.order
payment = rso.payment
elif rso:
order = rso.order
payment = None
else:
try:
order = event.orders.get(id=metadata['order'])
except Order.DoesNotExist:
return HttpResponse('Order not found', status=200)
payment = None
if order.payment_provider != prov.identifier:
prov = event.get_payment_providers()[order.payment_provider]
if not payment:
payment = order.payments.filter(
info__icontains=charge['id'],
provider__startswith='stripe',
amount=prov._amount_to_decimal(charge['amount']),
).last()
if not payment:
payment = order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED,
provider=SOURCE_TYPES.get(charge['source'].get('type', charge['source'].get('object', 'card')), 'stripe'),
amount=prov._amount_to_decimal(charge['amount']),
info=str(charge),
)
if payment.provider != prov.identifier:
prov = payment.payment_provider
prov._init_api()
order.log_action('pretix.plugins.stripe.event', data=event_json)
is_refund = charge['refunds']['total_count'] or charge['dispute']
if order.status == Order.STATUS_PAID and is_refund:
RequiredAction.objects.create(
event=event, action_type='pretix.plugins.stripe.refund', data=json.dumps({
'order': order.code,
'charge': charge_id
})
)
elif order.status == Order.STATUS_PAID and not order.payment_provider.startswith('stripe') and charge['status'] == 'succeeded' and not is_refund:
RequiredAction.objects.create(
event=event,
action_type='pretix.plugins.stripe.double',
data=json.dumps({
'order': order.code,
'charge': charge.id
})
)
elif order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and charge['status'] == 'succeeded' and not is_refund:
try:
mark_order_paid(order, user=None)
except LockTimeoutException:
return HttpResponse("Lock timeout, please try again.", status=503)
except Quota.QuotaExceededException:
if not RequiredAction.objects.filter(event=event, action_type='pretix.plugins.stripe.overpaid',
data__icontains=order.code).exists():
RequiredAction.objects.create(
event=event,
action_type='pretix.plugins.stripe.overpaid',
data=json.dumps({
'order': order.code,
'charge': charge.id
})
if is_refund:
known_refunds = [r.info_data.get('id') for r in payment.refunds.all()]
for r in charge['refunds']['data']:
if r['id'] not in known_refunds:
payment.create_external_refund(
amount=prov._amount_to_decimal(r['amount']),
info=str(r)
)
if charge['dispute']:
if charge['dispute']['status'] != 'won' and charge['dispute']['id'] not in known_refunds:
payment.create_external_refund(
amount=prov._amount_to_decimal(charge['dispute']['amount']),
info=str(charge['dispute'])
)
elif payment.state in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED):
if charge['status'] == 'succeeded':
try:
payment.confirm()
except LockTimeoutException:
return HttpResponse("Lock timeout, please try again.", status=503)
except Quota.QuotaExceededException:
pass
elif charge['status'] == 'failed':
payment.info = str(charge)
payment.state = OrderPayment.PAYMENT_STATE_FAILED
payment.save()
return HttpResponse(status=200)
def source_webhook(event, event_json, source_id):
def source_webhook(event, event_json, source_id, rso):
prov = StripeCC(event)
prov._init_api()
try:
@@ -245,24 +272,52 @@ def source_webhook(event, event_json, source_id):
return HttpResponse('Not interested in this event', status=200)
with transaction.atomic():
try:
order = event.orders.get(id=metadata['order'], payment_provider__startswith='stripe')
except Order.DoesNotExist:
return HttpResponse('Order not found', status=200)
if rso and rso.payment:
order = rso.payment.order
payment = rso.payment
elif rso:
order = rso.order
payment = None
else:
try:
order = event.orders.get(id=metadata['order'])
except Order.DoesNotExist:
return HttpResponse('Order not found', status=200)
payment = None
if order.payment_provider != prov.identifier:
prov = event.get_payment_providers()[order.payment_provider]
if not payment:
payment = order.payments.filter(
info__icontains=src['id'],
provider__startswith='stripe',
amount=prov._amount_to_decimal(src['amount']) if src['amount'] is not None else order.total,
).last()
if not payment:
payment = order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED,
provider=SOURCE_TYPES.get(src['type'], 'stripe'),
amount=prov._amount_to_decimal(src['amount']) if src['amount'] is not None else order.total,
info=str(src),
)
if payment.provider != prov.identifier:
prov = payment.payment_provider
prov._init_api()
order.log_action('pretix.plugins.stripe.event', data=event_json)
go = (event_json['type'] == 'source.chargeable' and order.status == Order.STATUS_PENDING and
go = (event_json['type'] == 'source.chargeable' and
payment.state in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED) and
src.status == 'chargeable')
if go:
try:
prov._charge_source(None, source_id, order)
prov._charge_source(None, source_id, payment)
except PaymentException:
logger.exception('Webhook error')
elif src.status == 'failed':
payment.info = str(src)
payment.state = OrderPayment.PAYMENT_STATE_FAILED
payment.save()
return HttpResponse(status=200)
@@ -285,32 +340,6 @@ def oauth_disconnect(request, **kwargs):
}))
@event_permission_required('can_change_orders')
@require_POST
def refund(request, **kwargs):
with transaction.atomic():
action = get_object_or_404(RequiredAction, event=request.event, pk=kwargs.get('id'),
action_type='pretix.plugins.stripe.refund', done=False)
data = json.loads(action.data)
action.done = True
action.user = request.user
action.save()
order = get_object_or_404(Order, event=request.event, code=data['order'])
if order.status != Order.STATUS_PAID:
messages.error(request, _('The order cannot be marked as refunded as it is not marked as paid!'))
else:
mark_order_refunded(order, user=request.user)
messages.success(
request, _('The order has been marked as refunded and the issue has been marked as resolved!')
)
return redirect(reverse('control:event.order', kwargs={
'organizer': request.event.organizer.slug,
'event': request.event.slug,
'code': data['order']
}))
class StripeOrderView:
def dispatch(self, request, *args, **kwargs):
try:
@@ -325,9 +354,15 @@ class StripeOrderView:
raise Http404('')
return super().dispatch(request, *args, **kwargs)
@cached_property
def payment(self):
return get_object_or_404(self.order.payments,
pk=self.kwargs['payment'],
provider__startswith='stripe')
@cached_property
def pprov(self):
return self.request.event.get_payment_providers()[self.order.payment_provider]
return self.request.event.get_payment_providers()[self.payment.provider]
@method_decorator(xframe_options_exempt, 'dispatch')
@@ -343,14 +378,15 @@ class ReturnView(StripeOrderView, View):
with transaction.atomic():
self.order.refresh_from_db()
if self.order.status == Order.STATUS_PAID:
self.payment.refresh_from_db()
if self.payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED:
if 'payment_stripe_token' in request.session:
del request.session['payment_stripe_token']
return self._redirect_to_order()
if src.status == 'chargeable':
try:
prov._charge_source(request, src.id, self.order)
prov._charge_source(request, src.id, self.payment)
except PaymentException as e:
messages.error(request, str(e))
return self._redirect_to_order()
@@ -358,6 +394,9 @@ class ReturnView(StripeOrderView, View):
if 'payment_stripe_token' in request.session:
del request.session['payment_stripe_token']
else:
self.payment.state = OrderPayment.PAYMENT_STATE_FAILED
self.payment.info = str(src)
self.payment.save()
messages.error(self.request, _('We had trouble authorizing your card payment. Please try again and '
'get in touch with us if this problem persists.'))
return self._redirect_to_order()

View File

@@ -484,7 +484,7 @@ class PaymentStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
try:
return prov.is_allowed(request, total=self._total_order_value)
except TypeError:
return prov.is_allowed(request)
return prov.is_allowed(request, )
def is_completed(self, request, warn=False):
self.request = request
@@ -622,7 +622,8 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep):
def get_order_url(self, order):
return eventreverse(self.request.event, 'presale:event.order.pay.complete', kwargs={
'order': order.code,
'secret': order.secret
'secret': order.secret,
'payment': order.payments.first().pk
})

View File

@@ -2,6 +2,7 @@
{% load i18n %}
{% load bootstrap3 %}
{% load eventsignal %}
{% load money %}
{% load eventurl %}
{% block title %}{% trans "Order details" %}{% endblock %}
{% block content %}
@@ -29,7 +30,7 @@
Please save the following link if you want to access your order later. We
also sent you an email containing the link to the address you specified.
{% endblocktrans %}<br>
<code>{{ url }}</code></p>
<code>{{ url }}</code></p>
<div class="clearfix"></div>
</div>
{% endif %}
@@ -43,28 +44,35 @@
{% if order.status == "n" %}
<div class="panel panel-danger">
<div class="panel-heading">
{% if can_change_method %}
<div class="pull-right">
<a href="{% eventurl event "presale:event.order.pay.change" secret=order.secret order=order.code %}">
<span class="fa fa-edit"></span>
{% trans "Use different payment method" %}
</a>
</div>
{% endif %}
<h3 class="panel-title">
{% trans "Payment" %}
</h3>
</div>
<div class="panel-body">
{% if can_retry %}
<a href="{% eventurl event "presale:event.order.pay" secret=order.secret order=order.code %}"
class="btn btn-primary pull-right"><i class="fa fa-money"></i> {% trans "Complete payment" %}
</a>
{% endif %}
{{ payment }}
<strong>{% blocktrans trimmed with total=pending_sum|money:request.event.currency %}
A payment of {{ total }} is still pending for this order.
{% endblocktrans %}</strong>
<strong>{% blocktrans trimmed with date=order.expires|date:"SHORT_DATE_FORMAT" %}
Please complete your payment before {{ date }}
{% endblocktrans %}</strong>
{% if last_payment %}
{{ last_payment_info }}
{% if can_pay %}
<div class="text-right">
<a href="{% eventurl event "presale:event.order.pay.change" secret=order.secret order=order.code %}"
class="btn btn-default">
{% trans "Re-try payment or choose another payment method" %}
</a>
</div>
{% endif %}
{% else %}
{% if can_pay %}
<div class="text-right">
<a href="{% eventurl event "presale:event.order.pay.change" secret=order.secret order=order.code %}"
class="btn btn-primary btn-lg">{% trans "Pay now" %}</a>
</div>
{% endif %}
{% endif %}
<div class="clearfix"></div>
</div>

View File

@@ -19,7 +19,7 @@
<div class="panel-heading">
<div class="pull-right">
<strong>
{% blocktrans trimmed with total=order.total|money:request.event.currency %}
{% blocktrans trimmed with total=payment.amount|money:request.event.currency %}
Total: {{ total }}
{% endblocktrans %}
</strong>
@@ -29,7 +29,7 @@
</h3>
</div>
<div class="panel-body">
{{ payment }}
{{ payment_info }}
</div>
</div>
</div>

View File

@@ -62,12 +62,13 @@ event_patterns = [
url(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/modify$',
pretix.presale.views.order.OrderModify.as_view(),
name='event.order.modify'),
url(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/pay$', pretix.presale.views.order.OrderPaymentStart.as_view(),
url(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/pay/(?P<payment>[0-9]+)/$',
pretix.presale.views.order.OrderPaymentStart.as_view(),
name='event.order.pay'),
url(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/pay/confirm$',
url(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/pay/(?P<payment>[0-9]+)/confirm$',
pretix.presale.views.order.OrderPaymentConfirm.as_view(),
name='event.order.pay.confirm'),
url(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/pay/complete$',
url(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/pay/(?P<payment>[0-9]+)/complete$',
pretix.presale.views.order.OrderPaymentComplete.as_view(),
name='event.order.pay.complete'),
url(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/pay/change',

View File

@@ -289,7 +289,6 @@ class EventIndex(EventViewMixin, CartMixin, TemplateView):
class EventIcalDownload(EventViewMixin, View):
def get(self, request, *args, **kwargs):
if not self.request.event:
raise Http404(_('Unknown event code or not authorized to access this event.'))

View File

@@ -5,7 +5,7 @@ from decimal import Decimal
from django.contrib import messages
from django.core.files import File
from django.db import transaction
from django.db.models import Sum
from django.db.models import Exists, OuterRef, Q, Sum
from django.http import FileResponse, Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.decorators import method_decorator
@@ -17,7 +17,7 @@ from django.views.generic import TemplateView, View
from pretix.base.models import CachedTicket, Invoice, Order, OrderPosition
from pretix.base.models.orders import (
CachedCombinedTicket, OrderFee, QuestionAnswer,
CachedCombinedTicket, OrderFee, OrderPayment, QuestionAnswer,
)
from pretix.base.payment import PaymentException
from pretix.base.services.invoices import (
@@ -54,10 +54,6 @@ class OrderDetailMixin(NoSearchIndexViewMixin):
else:
return None
@cached_property
def payment_provider(self):
return self.request.event.get_payment_providers().get(self.order.payment_provider)
def get_order_url(self):
return eventreverse(self.request.event, 'presale:event.order', kwargs={
'order': self.order.code,
@@ -131,24 +127,30 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TemplateView):
}
)
if self.order.status == Order.STATUS_PENDING and self.payment_provider:
ctx['payment'] = self.payment_provider.order_pending_render(self.request, self.order)
ctx['can_retry'] = (
self.payment_provider.order_can_retry(self.order)
and self.payment_provider.is_enabled
and self.order._can_be_paid()
)
if self.order.status == Order.STATUS_PENDING:
ctx['pending_sum'] = self.order.pending_sum
lp = self.order.payments.last()
ctx['can_pay'] = False
ctx['can_change_method'] = False
for provider in self.request.event.get_payment_providers().values():
if (provider.identifier != self.order.payment_provider and provider.is_enabled
and provider.order_change_allowed(self.order)):
ctx['can_change_method'] = True
if provider.is_enabled and provider.order_change_allowed(self.order):
ctx['can_pay'] = True
break
if lp and lp.state not in (OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED):
ctx['last_payment'] = self.order.payments.last()
pp = lp.payment_provider
ctx['last_payment_info'] = pp.payment_pending_render(self.request, ctx['last_payment'])
if lp.state == OrderPayment.PAYMENT_STATE_PENDING and not pp.abort_pending_allowed:
ctx['can_pay'] = False
ctx['can_pay'] = ctx['can_pay'] and self.order._can_be_paid()
elif self.order.status == Order.STATUS_PAID:
ctx['payment'] = self.payment_provider.order_paid_render(self.request, self.order) if self.payment_provider else ''
ctx['can_retry'] = False
ctx['can_pay'] = False
return ctx
@@ -165,8 +167,8 @@ class OrderPaymentStart(EventViewMixin, OrderDetailMixin, TemplateView):
if not self.order:
raise Http404(_('Unknown order code or not authorized to access this order.'))
if (self.order.status not in (Order.STATUS_PENDING, Order.STATUS_EXPIRED)
or not self.payment_provider.order_can_retry(self.order)
or not self.payment_provider.is_enabled):
or self.payment.state != OrderPayment.PAYMENT_STATE_CREATED
or not self.payment.payment_provider.is_enabled):
messages.error(request, _('The payment for this order cannot be continued.'))
return redirect(self.get_order_url())
@@ -177,7 +179,7 @@ class OrderPaymentStart(EventViewMixin, OrderDetailMixin, TemplateView):
return super().dispatch(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
resp = self.payment_provider.order_prepare(request, self.order)
resp = self.payment.payment_provider.payment_prepare(request, self.payment)
if 'payment_change_{}'.format(self.order.pk) in request.session:
del request.session['payment_change_{}'.format(self.order.pk)]
if isinstance(resp, str):
@@ -191,17 +193,22 @@ class OrderPaymentStart(EventViewMixin, OrderDetailMixin, TemplateView):
ctx = super().get_context_data(**kwargs)
ctx['order'] = self.order
ctx['form'] = self.form
ctx['provider'] = self.payment_provider
ctx['provider'] = self.payment.payment_provider
return ctx
@cached_property
def form(self):
return self.payment_provider.payment_form_render(self.request)
return self.payment.payment_provider.payment_form_render(self.request)
@cached_property
def payment(self):
return get_object_or_404(self.order.payments, pk=self.kwargs['payment'])
def get_confirm_url(self):
return eventreverse(self.request.event, 'presale:event.order.pay.confirm', kwargs={
'order': self.order.code,
'secret': self.order.secret
'secret': self.order.secret,
'payment': self.payment.pk
})
@@ -214,23 +221,32 @@ class OrderPaymentConfirm(EventViewMixin, OrderDetailMixin, TemplateView):
"""
template_name = "pretixpresale/event/order_pay_confirm.html"
@cached_property
def payment(self):
return get_object_or_404(self.order.payments, pk=self.kwargs['payment'])
def dispatch(self, request, *args, **kwargs):
self.request = request
if not self.order:
raise Http404(_('Unknown order code or not authorized to access this order.'))
can_do = self.payment_provider.order_can_retry(self.order) or 'payment_change_{}'.format(self.order.pk) in request.session
if not can_do or not self.payment_provider.is_enabled:
if self.payment.state != OrderPayment.PAYMENT_STATE_CREATED:
messages.error(request, _('The payment for this order cannot be continued.'))
return redirect(self.get_order_url())
if (not self.payment_provider.payment_is_valid_session(request)
or not self.payment_provider.is_enabled):
if (not self.payment.payment_provider.payment_is_valid_session(request) or
not self.payment.payment_provider.is_enabled):
messages.error(request, _('The payment information you entered was incomplete.'))
return redirect(self.get_payment_url())
term_last = self.order.payment_term_last
if term_last and now() > term_last:
messages.error(request, _('The payment is too late to be accepted.'))
return redirect(self.get_order_url())
return super().dispatch(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
try:
resp = self.payment_provider.payment_perform(request, self.order)
resp = self.payment.payment_provider.execute_payment(request, self.payment)
except PaymentException as e:
messages.error(request, str(e))
return redirect(self.get_order_url())
@@ -241,14 +257,16 @@ class OrderPaymentConfirm(EventViewMixin, OrderDetailMixin, TemplateView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['order'] = self.order
ctx['payment'] = self.payment_provider.checkout_confirm_render(self.request)
ctx['payment_provider'] = self.payment_provider
ctx['payment'] = self.payment
ctx['payment_info'] = self.payment.payment_provider.checkout_confirm_render(self.request)
ctx['payment_provider'] = self.payment.payment_provider
return ctx
def get_payment_url(self):
return eventreverse(self.request.event, 'presale:event.order.pay', kwargs={
'order': self.order.code,
'secret': self.order.secret
'secret': self.order.secret,
'payment': self.payment.pk
})
@@ -259,12 +277,20 @@ class OrderPaymentComplete(EventViewMixin, OrderDetailMixin, View):
details and confirmed them during the order process and we don't need to show them again,
we just need to perform the payment.
"""
@cached_property
def payment(self):
return get_object_or_404(self.order.payments, pk=self.kwargs['payment'])
def dispatch(self, request, *args, **kwargs):
self.request = request
if not self.order:
raise Http404(_('Unknown order code or not authorized to access this order.'))
if (not self.payment_provider.payment_is_valid_session(request) or
not self.payment_provider.is_enabled):
if self.payment.state != OrderPayment.PAYMENT_STATE_CREATED:
messages.error(request, _('The payment for this order cannot be continued.'))
return redirect(self.get_order_url())
if (not self.payment.payment_provider.payment_is_valid_session(request) or
not self.payment.payment_provider.is_enabled):
messages.error(request, _('The payment information you entered was incomplete.'))
return redirect(self.get_payment_url())
@@ -277,7 +303,7 @@ class OrderPaymentComplete(EventViewMixin, OrderDetailMixin, View):
def get(self, request, *args, **kwargs):
try:
resp = self.payment_provider.payment_perform(request, self.order)
resp = self.payment.payment_provider.execute_payment(request, self.payment)
except PaymentException as e:
messages.error(request, str(e))
return redirect(self.get_order_url())
@@ -290,6 +316,7 @@ class OrderPaymentComplete(EventViewMixin, OrderDetailMixin, View):
def get_payment_url(self):
return eventreverse(self.request.event, 'presale:event.order.pay', kwargs={
'order': self.order.code,
'payment': self.payment.pk,
'secret': self.order.secret
})
@@ -311,83 +338,133 @@ class OrderPayChangeMethod(EventViewMixin, OrderDetailMixin, TemplateView):
messages.error(request, _('The payment is too late to be accepted.'))
return redirect(self.get_order_url())
if self.open_payment:
pp = self.open_payment.payment_provider
if self.open_payment.state == OrderPayment.PAYMENT_STATE_PENDING and not pp.abort_pending_allowed:
messages.error(request, _('A payment is currently pending for this order.'))
return redirect(self.get_order_url())
return super().dispatch(request, *args, **kwargs)
def get_payment_url(self):
def get_payment_url(self, payment):
return eventreverse(self.request.event, 'presale:event.order.pay', kwargs={
'order': self.order.code,
'secret': self.order.secret
'secret': self.order.secret,
'payment': payment.pk
})
@cached_property
def _total_order_value(self):
def open_fees(self):
e = OrderPayment.objects.filter(
fee=OuterRef('pk'),
state=OrderPayment.PAYMENT_STATE_CONFIRMED
)
return self.order.fees.annotate(has_p=Exists(e)).filter(
Q(fee_type=OrderFee.FEE_TYPE_PAYMENT) & ~Q(has_p=True)
)
@cached_property
def open_payment(self):
lp = self.order.payments.last()
if lp and lp.state not in (OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED):
return lp
return None
@cached_property
def _position_sum(self):
return self.order.positions.aggregate(sum=Sum('price'))['sum']
@cached_property
def provider_forms(self):
providers = []
pending_sum = self.order.pending_sum
for provider in self.request.event.get_payment_providers().values():
if provider.identifier == self.order.payment_provider:
continue
if not provider.is_enabled or not provider.order_change_allowed(self.order):
continue
fee = provider.calculate_fee(self._total_order_value)
current_fee = self.order.fees.filter(fee_type=OrderFee.FEE_TYPE_PAYMENT).aggregate(s=Sum('value'))['s'] or Decimal('0.00')
current_fee = sum(f.value for f in self.open_fees) or Decimal('0.00')
fee = provider.calculate_fee(pending_sum - current_fee)
providers.append({
'provider': provider,
'fee': fee,
'fee_diff': fee - current_fee,
'fee_diff_abs': abs(fee - current_fee),
'total': abs(self._total_order_value + fee),
'total': abs(pending_sum + fee - current_fee),
'form': provider.payment_form_render(self.request)
})
return providers
def post(self, request, *args, **kwargs):
self.request = request
oldtotal = self.order.total
for p in self.provider_forms:
if p['provider'].identifier == request.POST.get('payment', ''):
request.session['payment'] = p['provider'].identifier
request.session['payment_change_{}'.format(self.order.pk)] = '1'
new_fee = p['provider'].calculate_fee(self._total_order_value)
fees = list(self.open_fees)
if fees:
fee = fees[0]
if len(fees) > 1:
for f in fees[1:]:
f.delete()
else:
fee = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.00'), order=self.order)
old_fee = fee.value
new_fee = p['provider'].calculate_fee(self.order.pending_sum - old_fee)
if new_fee:
fee = self.order.fees.get_or_create(fee_type=OrderFee.FEE_TYPE_PAYMENT, defaults={'value': 0})[0]
old_fee = fee.value
fee.value = new_fee
fee.internal_type = p['provider'].identifier
fee._calculate_tax()
fee.save()
else:
try:
fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT)
old_fee = fee.value
if fee.pk:
fee.delete()
except OrderFee.DoesNotExist:
old_fee = Decimal('0.00')
else:
fee = None
self.order.payment_provider = p['provider'].identifier
self.order.total = self._total_order_value + (self.order.fees.aggregate(sum=Sum('value'))['sum'] or 0)
if self.open_payment and self.open_payment.state in (OrderPayment.PAYMENT_STATE_PENDING,
OrderPayment.PAYMENT_STATE_CREATED):
self.open_payment.state = OrderPayment.PAYMENT_STATE_CANCELED
self.open_payment.save()
resp = p['provider'].order_prepare(request, self.order)
self.order.total = self._position_sum + (self.order.fees.aggregate(sum=Sum('value'))['sum'] or 0)
newpayment = self.order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED,
provider=p['provider'].identifier,
amount=self.order.pending_sum,
fee=fee
)
resp = p['provider'].payment_prepare(request, newpayment)
if resp:
with transaction.atomic():
self.order.log_action('pretix.event.order.payment.changed', {
'old_fee': old_fee,
'new_fee': new_fee,
'old_provider': self.order.payment_provider,
'new_provider': p['provider'].identifier
})
if self.open_payment and self.open_payment.provider != p['provider'].identifier:
self.order.log_action('pretix.event.order.payment.changed', {
'old_fee': old_fee,
'new_fee': new_fee,
'old_provider': self.open_payment.provider,
'new_provider': p['provider'].identifier,
'payment': newpayment.pk,
'local_id': newpayment.local_id,
})
else:
self.order.log_action('pretix.event.order.payment.started', {
'fee': new_fee,
'provider': p['provider'].identifier,
'payment': newpayment.pk,
'local_id': newpayment.local_id,
})
self.order.save()
i = self.order.invoices.filter(is_cancellation=False).last()
if i:
if i and self.order.total != oldtotal:
generate_cancellation(i)
generate_invoice(self.order)
if isinstance(resp, str):
return redirect(resp)
elif resp is True:
return redirect(self.get_confirm_url())
return redirect(self.get_confirm_url(newpayment))
else:
return self.get(request, *args, **kwargs)
messages.error(self.request, _("Please select a payment method."))
@@ -399,10 +476,11 @@ class OrderPayChangeMethod(EventViewMixin, OrderDetailMixin, TemplateView):
ctx['providers'] = self.provider_forms
return ctx
def get_confirm_url(self):
def get_confirm_url(self, payment):
return eventreverse(self.request.event, 'presale:event.order.pay.confirm', kwargs={
'order': self.order.code,
'secret': self.order.secret
'secret': self.order.secret,
'payment': payment.pk
})

View File

@@ -11,7 +11,7 @@
// Reset and dependencies
@import "bootstrap/normalize";
@import "bootstrap/print";
@import "bootstrap/glyphicons";
// @import "bootstrap/glyphicons";
// Core CSS
@import "bootstrap/scaffolding";

View File

@@ -77,6 +77,12 @@
}
.payments dt {
text-align: left;
font-weight: normal;
}
.alert {
text-align: left;
}

View File

@@ -394,6 +394,9 @@ body.loading #wrapper {
.dl-horizontal dt {
white-space: normal;
}
td > .dl-horizontal {
margin-bottom: 0;
}
.collapse-indicator {
-webkit-transition: all 150ms ease-in 0s;