forked from CGM_Public/pretix_original
Fix #571 -- Partial payments and refunds
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.')
|
||||
)),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
81
src/pretix/base/migrations/0096_auto_20180722_0801.py
Normal file
81
src/pretix/base/migrations/0096_auto_20180722_0801.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
118
src/pretix/base/migrations/0097_auto_20180722_0804.py
Normal file
118
src/pretix/base/migrations/0097_auto_20180722_0804.py
Normal 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',
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.'),
|
||||
|
||||
@@ -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 %}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }} · {{ line.subevent.get_date_range_display }}
|
||||
<br/>
|
||||
<span class="fa fa-calendar"></span> {{ line.subevent.name }} · {{ 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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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> </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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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> </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> </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 %}
|
||||
@@ -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>
|
||||
|
||||
104
src/pretix/control/templates/pretixcontrol/orders/refunds.html
Normal file
104
src/pretix/control/templates/pretixcontrol/orders/refunds.html
Normal 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 %}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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'})
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 %}
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
{% trans "This action cannot be undone." %}
|
||||
</form>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
{% load bootstrap3 %}
|
||||
{% bootstrap_form form %}
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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']
|
||||
}))
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
{% trans "This action cannot be undone." %}
|
||||
</form>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
{% load bootstrap3 %}
|
||||
{% bootstrap_form form %}
|
||||
@@ -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.
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.'))
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
// Reset and dependencies
|
||||
@import "bootstrap/normalize";
|
||||
@import "bootstrap/print";
|
||||
@import "bootstrap/glyphicons";
|
||||
// @import "bootstrap/glyphicons";
|
||||
|
||||
// Core CSS
|
||||
@import "bootstrap/scaffolding";
|
||||
|
||||
@@ -77,6 +77,12 @@
|
||||
}
|
||||
|
||||
|
||||
.payments dt {
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
|
||||
.alert {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -12,7 +12,6 @@ pytest-django
|
||||
isort
|
||||
pytest-mock==1.4.*
|
||||
pytest-rerunfailures==4.*
|
||||
pytest-warnings
|
||||
pytest-cache
|
||||
pytest-sugar
|
||||
responses
|
||||
|
||||
@@ -35,7 +35,7 @@ django-i18nfield>=1.2.1
|
||||
django-hijack==2.1.*
|
||||
django-oauth-toolkit==1.1.*
|
||||
# Stripe
|
||||
stripe==1.79.*
|
||||
stripe==2.0.*
|
||||
# PayPal
|
||||
paypalrestsdk==1.12.*
|
||||
pycparser==2.13 # https://github.com/eliben/pycparser/issues/147
|
||||
|
||||
@@ -100,7 +100,7 @@ setup(
|
||||
'pycparser==2.13',
|
||||
'django-redis==4.7.*',
|
||||
'redis==2.10.5',
|
||||
'stripe==1.79.*',
|
||||
'stripe==2.0.*',
|
||||
'chardet<3.1.0,>=3.0.2',
|
||||
'mt-940==4.7',
|
||||
'django-i18nfield>=1.2.1',
|
||||
|
||||
@@ -41,7 +41,7 @@ def order(event, item, other_item, taxrule):
|
||||
status=Order.STATUS_PAID, secret="k24fiuwvu8kxz3y1",
|
||||
datetime=datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=UTC),
|
||||
expires=datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=UTC),
|
||||
total=46, payment_provider='banktransfer', locale='en'
|
||||
total=46, locale='en'
|
||||
)
|
||||
InvoiceAddress.objects.create(order=o, company="Sample company", country=Country('NZ'))
|
||||
OrderPosition.objects.create(
|
||||
|
||||
@@ -31,7 +31,7 @@ def order(event, item, taxrule):
|
||||
status=Order.STATUS_PENDING, secret="k24fiuwvu8kxz3y1",
|
||||
datetime=datetime(2017, 12, 1, 10, 0, 0, tzinfo=UTC),
|
||||
expires=datetime(2017, 12, 10, 10, 0, 0, tzinfo=UTC),
|
||||
total=23, payment_provider='banktransfer', locale='en'
|
||||
total=23, locale='en'
|
||||
)
|
||||
o.fees.create(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.25'), tax_rate=Decimal('19.00'),
|
||||
tax_value=Decimal('0.05'), tax_rule=taxrule)
|
||||
@@ -479,7 +479,7 @@ def test_event_update_live_no_product(token_client, organizer, event):
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_event_update_live_no_payment_method(token_client, organizer, event, item):
|
||||
def test_event_update_live_no_payment_method(token_client, organizer, event, item, free_quota):
|
||||
resp = token_client.patch(
|
||||
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
|
||||
{
|
||||
|
||||
@@ -43,7 +43,7 @@ def order(event, item, taxrule):
|
||||
status=Order.STATUS_PENDING, secret="k24fiuwvu8kxz3y1",
|
||||
datetime=datetime(2017, 12, 1, 10, 0, 0, tzinfo=UTC),
|
||||
expires=datetime(2017, 12, 10, 10, 0, 0, tzinfo=UTC),
|
||||
total=23, payment_provider='banktransfer', locale='en'
|
||||
total=23, locale='en'
|
||||
)
|
||||
o.fees.create(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.25'), tax_rate=Decimal('19.00'),
|
||||
tax_value=Decimal('0.05'), tax_rule=taxrule)
|
||||
|
||||
@@ -9,9 +9,13 @@ from django.core import mail as djmail
|
||||
from django.utils.timezone import now
|
||||
from django_countries.fields import Country
|
||||
from pytz import UTC
|
||||
from stripe.error import APIConnectionError
|
||||
from tests.plugins.stripe.test_provider import MockedCharge
|
||||
|
||||
from pretix.base.models import InvoiceAddress, Order, OrderPosition, Question
|
||||
from pretix.base.models.orders import CartPosition, OrderFee
|
||||
from pretix.base.models.orders import (
|
||||
CartPosition, OrderFee, OrderPayment, OrderRefund,
|
||||
)
|
||||
from pretix.base.services.invoices import (
|
||||
generate_cancellation, generate_invoice,
|
||||
)
|
||||
@@ -57,6 +61,8 @@ def quota(event, item):
|
||||
@pytest.fixture
|
||||
def order(event, item, taxrule, question):
|
||||
testtime = datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=UTC)
|
||||
event.plugins += ",pretix.plugins.stripe"
|
||||
event.save()
|
||||
|
||||
with mock.patch('django.utils.timezone.now') as mock_now:
|
||||
mock_now.return_value = testtime
|
||||
@@ -65,7 +71,26 @@ def order(event, item, taxrule, question):
|
||||
status=Order.STATUS_PENDING, secret="k24fiuwvu8kxz3y1",
|
||||
datetime=datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=UTC),
|
||||
expires=datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=UTC),
|
||||
total=23, payment_provider='banktransfer', locale='en'
|
||||
total=23, locale='en'
|
||||
)
|
||||
p1 = o.payments.create(
|
||||
provider='stripe',
|
||||
state='refunded',
|
||||
amount=Decimal('23.00'),
|
||||
payment_date=testtime,
|
||||
)
|
||||
o.refunds.create(
|
||||
provider='stripe',
|
||||
state='done',
|
||||
source='admin',
|
||||
amount=Decimal('23.00'),
|
||||
execution_date=testtime,
|
||||
payment=p1,
|
||||
)
|
||||
o.payments.create(
|
||||
provider='banktransfer',
|
||||
state='pending',
|
||||
amount=Decimal('23.00'),
|
||||
)
|
||||
o.fees.create(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.25'), tax_rate=Decimal('19.00'),
|
||||
tax_value=Decimal('0.05'), tax_rule=taxrule)
|
||||
@@ -112,6 +137,36 @@ TEST_ORDERPOSITION_RES = {
|
||||
],
|
||||
"subevent": None
|
||||
}
|
||||
TEST_PAYMENTS_RES = [
|
||||
{
|
||||
"local_id": 1,
|
||||
"created": "2017-12-01T10:00:00Z",
|
||||
"payment_date": "2017-12-01T10:00:00Z",
|
||||
"provider": "stripe",
|
||||
"state": "refunded",
|
||||
"amount": "23.00"
|
||||
},
|
||||
{
|
||||
"local_id": 2,
|
||||
"created": "2017-12-01T10:00:00Z",
|
||||
"payment_date": None,
|
||||
"provider": "banktransfer",
|
||||
"state": "pending",
|
||||
"amount": "23.00"
|
||||
}
|
||||
]
|
||||
TEST_REFUNDS_RES = [
|
||||
{
|
||||
"local_id": 1,
|
||||
"payment": 1,
|
||||
"source": "admin",
|
||||
"created": "2017-12-01T10:00:00Z",
|
||||
"execution_date": "2017-12-01T10:00:00Z",
|
||||
"provider": "stripe",
|
||||
"state": "done",
|
||||
"amount": "23.00"
|
||||
},
|
||||
]
|
||||
TEST_ORDER_RES = {
|
||||
"code": "FOO",
|
||||
"status": "n",
|
||||
@@ -120,7 +175,7 @@ TEST_ORDER_RES = {
|
||||
"locale": "en",
|
||||
"datetime": "2017-12-01T10:00:00Z",
|
||||
"expires": "2017-12-10T10:00:00Z",
|
||||
"payment_date": None,
|
||||
"payment_date": "2017-12-01",
|
||||
"fees": [
|
||||
{
|
||||
"fee_type": "payment",
|
||||
@@ -149,7 +204,9 @@ TEST_ORDER_RES = {
|
||||
"vat_id_validated": False
|
||||
},
|
||||
"positions": [TEST_ORDERPOSITION_RES],
|
||||
"downloads": []
|
||||
"downloads": [],
|
||||
"payments": TEST_PAYMENTS_RES,
|
||||
"refunds": TEST_REFUNDS_RES,
|
||||
}
|
||||
|
||||
|
||||
@@ -226,6 +283,252 @@ def test_order_detail(token_client, organizer, event, order, item, taxrule, ques
|
||||
assert len(resp.data['positions'][0]['downloads']) == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_payment_list(token_client, organizer, event, order):
|
||||
resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/{}/payments/'.format(organizer.slug, event.slug,
|
||||
order.code))
|
||||
assert resp.status_code == 200
|
||||
assert TEST_PAYMENTS_RES == resp.data['results']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_payment_detail(token_client, organizer, event, order):
|
||||
resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/{}/payments/1/'.format(organizer.slug, event.slug,
|
||||
order.code))
|
||||
assert resp.status_code == 200
|
||||
assert TEST_PAYMENTS_RES[0] == resp.data
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_payment_confirm(token_client, organizer, event, order):
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/payments/2/confirm/'.format(
|
||||
organizer.slug, event.slug, order.code
|
||||
), format='json', data={'force': True})
|
||||
p = order.payments.get(local_id=2)
|
||||
assert resp.status_code == 200
|
||||
assert p.state == OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/payments/2/confirm/'.format(
|
||||
organizer.slug, event.slug, order.code
|
||||
), format='json', data={'force': True})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_payment_cancel(token_client, organizer, event, order):
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/payments/2/cancel/'.format(
|
||||
organizer.slug, event.slug, order.code
|
||||
))
|
||||
p = order.payments.get(local_id=2)
|
||||
assert resp.status_code == 200
|
||||
assert p.state == OrderPayment.PAYMENT_STATE_CANCELED
|
||||
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/payments/2/cancel/'.format(
|
||||
organizer.slug, event.slug, order.code
|
||||
))
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_payment_refund_fail(token_client, organizer, event, order, monkeypatch):
|
||||
order.payments.last().confirm()
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/payments/2/refund/'.format(
|
||||
organizer.slug, event.slug, order.code
|
||||
), format='json', data={
|
||||
'amount': '25.00',
|
||||
'mark_refunded': False
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
assert resp.data == {'amount': ['Invalid refund amount, only 23.00 are available to refund.']}
|
||||
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/payments/2/refund/'.format(
|
||||
organizer.slug, event.slug, order.code
|
||||
), format='json', data={
|
||||
'amount': '20.00',
|
||||
'mark_refunded': False
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
assert resp.data == {'amount': ['Partial refund not available for this payment method.']}
|
||||
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/payments/2/refund/'.format(
|
||||
organizer.slug, event.slug, order.code
|
||||
), format='json', data={
|
||||
'mark_refunded': False
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
assert resp.data == {'amount': ['Full refund not available for this payment method.']}
|
||||
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/payments/2/refund/'.format(
|
||||
organizer.slug, event.slug, order.code
|
||||
), format='json', data={
|
||||
'amount': '23.00',
|
||||
'mark_refunded': False
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
assert resp.data == {'amount': ['Full refund not available for this payment method.']}
|
||||
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/payments/1/refund/'.format(
|
||||
organizer.slug, event.slug, order.code
|
||||
), format='json', data={
|
||||
'amount': '23.00',
|
||||
'mark_refunded': False
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
assert resp.data == {'detail': 'Invalid state of payment.'}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_payment_refund_success(token_client, organizer, event, order, monkeypatch):
|
||||
def charge_retr(*args, **kwargs):
|
||||
def refund_create(amount):
|
||||
pass
|
||||
|
||||
c = MockedCharge()
|
||||
c.refunds.create = refund_create
|
||||
return c
|
||||
|
||||
p1 = order.payments.create(
|
||||
provider='stripe',
|
||||
state='confirmed',
|
||||
amount=Decimal('23.00'),
|
||||
payment_date=order.datetime,
|
||||
info=json.dumps({
|
||||
'id': 'ch_123345345'
|
||||
})
|
||||
)
|
||||
monkeypatch.setattr("stripe.Charge.retrieve", charge_retr)
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/payments/{}/refund/'.format(
|
||||
organizer.slug, event.slug, order.code, p1.local_id
|
||||
), format='json', data={
|
||||
'amount': '23.00',
|
||||
'mark_refunded': False,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
r = order.refunds.get(local_id=resp.data['local_id'])
|
||||
assert r.provider == "stripe"
|
||||
assert r.state == OrderRefund.REFUND_STATE_DONE
|
||||
assert r.source == OrderRefund.REFUND_SOURCE_ADMIN
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_payment_refund_unavailable(token_client, organizer, event, order, monkeypatch):
|
||||
def charge_retr(*args, **kwargs):
|
||||
def refund_create(amount):
|
||||
raise APIConnectionError(message='Foo')
|
||||
|
||||
c = MockedCharge()
|
||||
c.refunds.create = refund_create
|
||||
return c
|
||||
|
||||
p1 = order.payments.create(
|
||||
provider='stripe',
|
||||
state='confirmed',
|
||||
amount=Decimal('23.00'),
|
||||
payment_date=order.datetime,
|
||||
info=json.dumps({
|
||||
'id': 'ch_123345345'
|
||||
})
|
||||
)
|
||||
monkeypatch.setattr("stripe.Charge.retrieve", charge_retr)
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/payments/{}/refund/'.format(
|
||||
organizer.slug, event.slug, order.code, p1.local_id
|
||||
), format='json', data={
|
||||
'amount': '23.00',
|
||||
'mark_refunded': False,
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
assert resp.data == {'detail': 'External error: We had trouble communicating with Stripe. Please try again and contact support if the problem persists.'}
|
||||
r = order.refunds.last()
|
||||
assert r.provider == "stripe"
|
||||
assert r.state == OrderRefund.REFUND_STATE_FAILED
|
||||
assert r.source == OrderRefund.REFUND_SOURCE_ADMIN
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refund_list(token_client, organizer, event, order):
|
||||
resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/{}/refunds/'.format(organizer.slug, event.slug,
|
||||
order.code))
|
||||
assert resp.status_code == 200
|
||||
assert TEST_REFUNDS_RES == resp.data['results']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refund_detail(token_client, organizer, event, order):
|
||||
resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/{}/refunds/1/'.format(organizer.slug, event.slug,
|
||||
order.code))
|
||||
assert resp.status_code == 200
|
||||
assert TEST_REFUNDS_RES[0] == resp.data
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refund_done(token_client, organizer, event, order):
|
||||
r = order.refunds.get(local_id=1)
|
||||
r.state = 'transit'
|
||||
r.save()
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/refunds/1/done/'.format(
|
||||
organizer.slug, event.slug, order.code
|
||||
))
|
||||
r = order.refunds.get(local_id=1)
|
||||
assert resp.status_code == 200
|
||||
assert r.state == OrderRefund.REFUND_STATE_DONE
|
||||
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/refunds/1/done/'.format(
|
||||
organizer.slug, event.slug, order.code
|
||||
))
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refund_process_mark_refunded(token_client, organizer, event, order):
|
||||
p = order.payments.get(local_id=1)
|
||||
p.create_external_refund()
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/refunds/2/process/'.format(
|
||||
organizer.slug, event.slug, order.code
|
||||
), format='json', data={'mark_refunded': True})
|
||||
r = order.refunds.get(local_id=1)
|
||||
assert resp.status_code == 200
|
||||
assert r.state == OrderRefund.REFUND_STATE_DONE
|
||||
order.refresh_from_db()
|
||||
assert order.status == Order.STATUS_REFUNDED
|
||||
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/refunds/2/process/'.format(
|
||||
organizer.slug, event.slug, order.code
|
||||
), format='json', data={'mark_refunded': True})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refund_process_mark_pending(token_client, organizer, event, order):
|
||||
p = order.payments.get(local_id=1)
|
||||
p.create_external_refund()
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/refunds/2/process/'.format(
|
||||
organizer.slug, event.slug, order.code
|
||||
), format='json', data={'mark_refunded': False})
|
||||
r = order.refunds.get(local_id=1)
|
||||
assert resp.status_code == 200
|
||||
assert r.state == OrderRefund.REFUND_STATE_DONE
|
||||
order.refresh_from_db()
|
||||
assert order.status == Order.STATUS_PENDING
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refund_cancel(token_client, organizer, event, order):
|
||||
r = order.refunds.get(local_id=1)
|
||||
r.state = 'transit'
|
||||
r.save()
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/refunds/1/cancel/'.format(
|
||||
organizer.slug, event.slug, order.code
|
||||
))
|
||||
r = order.refunds.get(local_id=1)
|
||||
assert resp.status_code == 200
|
||||
assert r.state == OrderRefund.REFUND_STATE_CANCELED
|
||||
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/refunds/1/cancel/'.format(
|
||||
organizer.slug, event.slug, order.code
|
||||
))
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_orderposition_list(token_client, organizer, event, order, item, subevent, subevent2, question):
|
||||
i2 = copy.copy(item)
|
||||
@@ -860,7 +1163,12 @@ def test_order_create(token_client, organizer, event, item, quota, question):
|
||||
assert o.locale == "en"
|
||||
assert o.total == Decimal('23.25')
|
||||
assert o.status == Order.STATUS_PENDING
|
||||
assert o.payment_provider == "banktransfer"
|
||||
|
||||
p = o.payments.first()
|
||||
assert p.provider == "banktransfer"
|
||||
assert p.amount == o.total
|
||||
assert p.state == "created"
|
||||
|
||||
fee = o.fees.first()
|
||||
assert fee.fee_type == "payment"
|
||||
assert fee.value == Decimal('0.25')
|
||||
@@ -953,7 +1261,6 @@ def test_order_create_payment_info_optional(token_client, organizer, event, item
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
o = Order.objects.get(code=resp.data['code'])
|
||||
assert not o.payment_info == "{}"
|
||||
|
||||
res['payment_info'] = {
|
||||
'foo': {
|
||||
@@ -968,7 +1275,11 @@ def test_order_create_payment_info_optional(token_client, organizer, event, item
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
o = Order.objects.get(code=resp.data['code'])
|
||||
assert json.loads(o.payment_info) == res['payment_info']
|
||||
|
||||
p = o.payments.first()
|
||||
assert p.provider == "banktransfer"
|
||||
assert p.amount == o.total
|
||||
assert json.loads(p.info) == res['payment_info']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -1725,7 +2036,11 @@ def test_order_create_free(token_client, organizer, event, item, quota, question
|
||||
o = Order.objects.get(code=resp.data['code'])
|
||||
assert o.total == Decimal('0.00')
|
||||
assert o.status == Order.STATUS_PAID
|
||||
assert o.payment_provider == "free"
|
||||
|
||||
p = o.payments.first()
|
||||
assert p.provider == "free"
|
||||
assert p.amount == o.total
|
||||
assert p.state == "confirmed"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -1803,3 +2118,76 @@ def test_order_create_paid_generate_invoice(token_client, organizer, event, item
|
||||
assert resp.status_code == 201
|
||||
o = Order.objects.get(code=resp.data['code'])
|
||||
assert o.invoices.count() == 1
|
||||
|
||||
p = o.payments.first()
|
||||
assert p.provider == "banktransfer"
|
||||
assert p.amount == o.total
|
||||
assert p.state == "confirmed"
|
||||
|
||||
|
||||
REFUND_CREATE_PAYLOAD = {
|
||||
"state": "created",
|
||||
"provider": "manual",
|
||||
"amount": "23.00",
|
||||
"source": "admin",
|
||||
"payment": 2,
|
||||
"info": {
|
||||
"foo": "bar",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refund_create(token_client, organizer, event, order):
|
||||
res = copy.deepcopy(REFUND_CREATE_PAYLOAD)
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/{}/refunds/'.format(
|
||||
organizer.slug, event.slug, order.code
|
||||
), format='json', data=res
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
r = order.refunds.get(local_id=resp.data['local_id'])
|
||||
assert r.provider == "manual"
|
||||
assert r.amount == Decimal("23.00")
|
||||
assert r.state == "created"
|
||||
assert r.source == "admin"
|
||||
assert r.info_data == {"foo": "bar"}
|
||||
assert r.payment.local_id == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refund_optional_fields(token_client, organizer, event, order):
|
||||
res = copy.deepcopy(REFUND_CREATE_PAYLOAD)
|
||||
del res['info']
|
||||
del res['payment']
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/{}/refunds/'.format(
|
||||
organizer.slug, event.slug, order.code
|
||||
), format='json', data=res
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
r = order.refunds.get(local_id=resp.data['local_id'])
|
||||
assert r.provider == "manual"
|
||||
assert r.amount == Decimal("23.00")
|
||||
assert r.state == "created"
|
||||
assert r.source == "admin"
|
||||
|
||||
del res['state']
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/{}/refunds/'.format(
|
||||
organizer.slug, event.slug, order.code
|
||||
), format='json', data=res
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refund_create_invalid_payment(token_client, organizer, event, order):
|
||||
res = copy.deepcopy(REFUND_CREATE_PAYLOAD)
|
||||
res['payment'] = 7
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/{}/refunds/'.format(
|
||||
organizer.slug, event.slug, order.code
|
||||
), format='json', data=res
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@@ -73,6 +73,16 @@ event_permission_sub_urls = [
|
||||
('post', 'can_change_orders', 'orders/ABC12/mark_expired/', 404),
|
||||
('post', 'can_change_orders', 'orders/ABC12/mark_canceled/', 404),
|
||||
('post', 'can_change_orders', 'orders/ABC12/extend/', 400),
|
||||
('get', 'can_view_orders', 'orders/ABC12/payments/', 404),
|
||||
('get', 'can_view_orders', 'orders/ABC12/payments/1/', 404),
|
||||
('get', 'can_view_orders', 'orders/ABC12/refunds/', 404),
|
||||
('get', 'can_view_orders', 'orders/ABC12/refunds/1/', 404),
|
||||
('post', 'can_change_orders', 'orders/ABC12/payments/1/confirm/', 404),
|
||||
('post', 'can_change_orders', 'orders/ABC12/payments/1/refund/', 404),
|
||||
('post', 'can_change_orders', 'orders/ABC12/payments/1/cancel/', 404),
|
||||
('post', 'can_change_orders', 'orders/ABC12/refunds/1/cancel/', 404),
|
||||
('post', 'can_change_orders', 'orders/ABC12/refunds/1/process/', 404),
|
||||
('post', 'can_change_orders', 'orders/ABC12/refunds/1/done/', 404),
|
||||
('get', 'can_view_orders', 'checkinlists/', 200),
|
||||
('post', 'can_change_event_settings', 'checkinlists/', 400),
|
||||
('put', 'can_change_event_settings', 'checkinlists/1/', 404),
|
||||
|
||||
@@ -32,7 +32,7 @@ def env():
|
||||
code='FOO', event=event, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=0, payment_provider='banktransfer', locale='en'
|
||||
total=0, locale='en'
|
||||
)
|
||||
tr = event.tax_rules.create(rate=Decimal('19.00'))
|
||||
o.fees.create(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.25'), tax_rate=Decimal('19.00'),
|
||||
@@ -275,7 +275,7 @@ def test_invoice_numbers(env):
|
||||
code='BAR', event=event, email='dummy2@dummy.test',
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=0, payment_provider='banktransfer',
|
||||
total=0,
|
||||
locale='en'
|
||||
)
|
||||
order2.fees.create(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.25'), tax_rate=Decimal('0.00'),
|
||||
@@ -322,7 +322,7 @@ def test_invoice_number_prefixes(env):
|
||||
event=event2, email='dummy2@dummy.test',
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=0, payment_provider='banktransfer',
|
||||
total=0,
|
||||
locale='en'
|
||||
)
|
||||
order2.fees.create(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.25'), tax_rate=Decimal('0.00'),
|
||||
|
||||
@@ -16,15 +16,13 @@ from django.utils.timezone import now
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedFile, CartPosition, CheckinList, Event, Item, ItemCategory,
|
||||
ItemVariation, Order, OrderPosition, Organizer, Question, Quota, User,
|
||||
Voucher, WaitingListEntry,
|
||||
ItemVariation, Order, OrderPayment, OrderPosition, OrderRefund, Organizer,
|
||||
Question, Quota, User, Voucher, WaitingListEntry,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.items import SubEventItem, SubEventItemVariation
|
||||
from pretix.base.reldate import RelativeDate, RelativeDateWrapper
|
||||
from pretix.base.services.orders import (
|
||||
OrderError, cancel_order, mark_order_paid, perform_order,
|
||||
)
|
||||
from pretix.base.services.orders import OrderError, cancel_order, perform_order
|
||||
|
||||
|
||||
class UserTestCase(TestCase):
|
||||
@@ -600,7 +598,9 @@ class OrderTestCase(BaseQuotaTestCase):
|
||||
def test_paid_in_time(self):
|
||||
self.quota.size = 0
|
||||
self.quota.save()
|
||||
mark_order_paid(self.order)
|
||||
self.order.payments.create(
|
||||
provider='manual', amount=self.order.total
|
||||
).confirm()
|
||||
self.order = Order.objects.get(id=self.order.id)
|
||||
self.assertEqual(self.order.status, Order.STATUS_PAID)
|
||||
|
||||
@@ -609,7 +609,9 @@ class OrderTestCase(BaseQuotaTestCase):
|
||||
self.order.status = Order.STATUS_EXPIRED
|
||||
self.order.expires = now() - timedelta(days=2)
|
||||
self.order.save()
|
||||
mark_order_paid(self.order)
|
||||
self.order.payments.create(
|
||||
provider='manual', amount=self.order.total
|
||||
).confirm()
|
||||
self.order = Order.objects.get(id=self.order.id)
|
||||
self.assertEqual(self.order.status, Order.STATUS_PAID)
|
||||
|
||||
@@ -619,7 +621,9 @@ class OrderTestCase(BaseQuotaTestCase):
|
||||
self.order.expires = now() - timedelta(days=2)
|
||||
self.order.save()
|
||||
with self.assertRaises(Quota.QuotaExceededException):
|
||||
mark_order_paid(self.order)
|
||||
self.order.payments.create(
|
||||
provider='manual', amount=self.order.total
|
||||
).confirm()
|
||||
self.order = Order.objects.get(id=self.order.id)
|
||||
self.assertEqual(self.order.status, Order.STATUS_EXPIRED)
|
||||
|
||||
@@ -640,7 +644,9 @@ class OrderTestCase(BaseQuotaTestCase):
|
||||
self.order.expires = now() - timedelta(days=2)
|
||||
self.order.save()
|
||||
with self.assertRaises(Quota.QuotaExceededException):
|
||||
mark_order_paid(self.order)
|
||||
self.order.payments.create(
|
||||
provider='manual', amount=self.order.total
|
||||
).confirm()
|
||||
self.order = Order.objects.get(id=self.order.id)
|
||||
self.assertEqual(self.order.status, Order.STATUS_EXPIRED)
|
||||
self.event.has_subevents = False
|
||||
@@ -652,7 +658,9 @@ class OrderTestCase(BaseQuotaTestCase):
|
||||
self.order.expires = now() - timedelta(days=2)
|
||||
self.order.save()
|
||||
with self.assertRaises(Quota.QuotaExceededException):
|
||||
mark_order_paid(self.order)
|
||||
self.order.payments.create(
|
||||
provider='manual', amount=self.order.total
|
||||
).confirm()
|
||||
self.order = Order.objects.get(id=self.order.id)
|
||||
self.assertEqual(self.order.status, Order.STATUS_EXPIRED)
|
||||
|
||||
@@ -664,7 +672,9 @@ class OrderTestCase(BaseQuotaTestCase):
|
||||
self.quota.size = 0
|
||||
self.quota.save()
|
||||
with self.assertRaises(Quota.QuotaExceededException):
|
||||
mark_order_paid(self.order)
|
||||
self.order.payments.create(
|
||||
provider='manual', amount=self.order.total
|
||||
).confirm()
|
||||
self.order = Order.objects.get(id=self.order.id)
|
||||
self.assertIn(self.order.status, (Order.STATUS_PENDING, Order.STATUS_EXPIRED))
|
||||
|
||||
@@ -672,7 +682,9 @@ class OrderTestCase(BaseQuotaTestCase):
|
||||
self.event.settings.payment_term_accept_late = True
|
||||
self.order.expires = now() - timedelta(days=2)
|
||||
self.order.save()
|
||||
mark_order_paid(self.order)
|
||||
self.order.payments.create(
|
||||
provider='manual', amount=self.order.total
|
||||
).confirm()
|
||||
self.order = Order.objects.get(id=self.order.id)
|
||||
self.assertEqual(self.order.status, Order.STATUS_PAID)
|
||||
|
||||
@@ -683,7 +695,9 @@ class OrderTestCase(BaseQuotaTestCase):
|
||||
self.order.save()
|
||||
self.quota.size = 0
|
||||
self.quota.save()
|
||||
mark_order_paid(self.order, force=True)
|
||||
self.order.payments.create(
|
||||
provider='manual', amount=self.order.total
|
||||
).confirm(force=True)
|
||||
self.order = Order.objects.get(id=self.order.id)
|
||||
self.assertEqual(self.order.status, Order.STATUS_PAID)
|
||||
|
||||
@@ -696,7 +710,9 @@ class OrderTestCase(BaseQuotaTestCase):
|
||||
self.quota.size = 2
|
||||
self.quota.save()
|
||||
with self.assertRaises(Quota.QuotaExceededException):
|
||||
mark_order_paid(self.order)
|
||||
self.order.payments.create(
|
||||
provider='manual', amount=self.order.total
|
||||
).confirm()
|
||||
self.order = Order.objects.get(id=self.order.id)
|
||||
self.assertEqual(self.order.status, Order.STATUS_EXPIRED)
|
||||
|
||||
@@ -707,7 +723,9 @@ class OrderTestCase(BaseQuotaTestCase):
|
||||
self.order.save()
|
||||
self.quota.size = 2
|
||||
self.quota.save()
|
||||
mark_order_paid(self.order, count_waitinglist=False)
|
||||
self.order.payments.create(
|
||||
provider='manual', amount=self.order.total
|
||||
).confirm(count_waitinglist=False)
|
||||
self.order = Order.objects.get(id=self.order.id)
|
||||
self.assertEqual(self.order.status, Order.STATUS_PAID)
|
||||
|
||||
@@ -866,6 +884,144 @@ class OrderTestCase(BaseQuotaTestCase):
|
||||
assert p1.secret != p2.secret
|
||||
assert self.order.can_user_cancel is False
|
||||
|
||||
def test_paid_order_underpaid(self):
|
||||
self.order.status = Order.STATUS_PAID
|
||||
self.order.save()
|
||||
self.order.payments.create(
|
||||
amount=Decimal('46.00'),
|
||||
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||
provider='manual'
|
||||
)
|
||||
self.order.refunds.create(
|
||||
amount=Decimal('10.00'),
|
||||
state=OrderRefund.REFUND_STATE_DONE,
|
||||
provider='manual'
|
||||
)
|
||||
assert self.order.pending_sum == Decimal('10.00')
|
||||
o = Order.annotate_overpayments(Order.objects.all()).first()
|
||||
assert o.is_underpaid
|
||||
assert not o.is_overpaid
|
||||
assert not o.has_pending_refund
|
||||
assert not o.has_external_refund
|
||||
|
||||
def test_pending_order_underpaid(self):
|
||||
self.order.payments.create(
|
||||
amount=Decimal('46.00'),
|
||||
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||
provider='manual'
|
||||
)
|
||||
self.order.refunds.create(
|
||||
amount=Decimal('10.00'),
|
||||
state=OrderRefund.REFUND_STATE_DONE,
|
||||
provider='manual'
|
||||
)
|
||||
assert self.order.pending_sum == Decimal('10.00')
|
||||
o = Order.annotate_overpayments(Order.objects.all()).first()
|
||||
assert not o.is_underpaid
|
||||
assert not o.is_overpaid
|
||||
assert not o.has_pending_refund
|
||||
assert not o.has_external_refund
|
||||
|
||||
def test_canceled_order_overpaid(self):
|
||||
self.order.status = Order.STATUS_CANCELED
|
||||
self.order.save()
|
||||
self.order.payments.create(
|
||||
amount=Decimal('46.00'),
|
||||
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||
provider='manual'
|
||||
)
|
||||
self.order.refunds.create(
|
||||
amount=Decimal('10.00'),
|
||||
state=OrderRefund.REFUND_STATE_DONE,
|
||||
provider='manual'
|
||||
)
|
||||
assert self.order.pending_sum == Decimal('-36.00')
|
||||
o = Order.annotate_overpayments(Order.objects.all()).first()
|
||||
assert not o.is_underpaid
|
||||
assert o.is_overpaid
|
||||
assert not o.has_pending_refund
|
||||
assert not o.has_external_refund
|
||||
|
||||
def test_paid_order_external_refund(self):
|
||||
self.order.status = Order.STATUS_PAID
|
||||
self.order.save()
|
||||
self.order.payments.create(
|
||||
amount=Decimal('46.00'),
|
||||
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||
provider='manual'
|
||||
)
|
||||
self.order.refunds.create(
|
||||
amount=Decimal('10.00'),
|
||||
state=OrderRefund.REFUND_STATE_EXTERNAL,
|
||||
provider='manual'
|
||||
)
|
||||
assert self.order.pending_sum == Decimal('0.00')
|
||||
o = Order.annotate_overpayments(Order.objects.all()).first()
|
||||
assert not o.is_underpaid
|
||||
assert not o.is_overpaid
|
||||
assert not o.has_pending_refund
|
||||
assert o.has_external_refund
|
||||
|
||||
def test_pending_order_pending_refund(self):
|
||||
self.order.status = Order.STATUS_REFUNDED
|
||||
self.order.save()
|
||||
self.order.payments.create(
|
||||
amount=Decimal('46.00'),
|
||||
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||
provider='manual'
|
||||
)
|
||||
self.order.refunds.create(
|
||||
amount=Decimal('46.00'),
|
||||
state=OrderRefund.REFUND_STATE_CREATED,
|
||||
provider='manual'
|
||||
)
|
||||
assert self.order.pending_sum == Decimal('0.00')
|
||||
o = Order.annotate_overpayments(Order.objects.all()).first()
|
||||
assert not o.is_underpaid
|
||||
assert not o.is_overpaid
|
||||
assert o.has_pending_refund
|
||||
assert not o.has_external_refund
|
||||
|
||||
def test_paid_order_overpaid(self):
|
||||
self.order.status = Order.STATUS_PAID
|
||||
self.order.save()
|
||||
self.order.payments.create(
|
||||
amount=Decimal('66.00'),
|
||||
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||
provider='manual'
|
||||
)
|
||||
self.order.refunds.create(
|
||||
amount=Decimal('10.00'),
|
||||
state=OrderRefund.REFUND_STATE_DONE,
|
||||
provider='manual'
|
||||
)
|
||||
assert self.order.pending_sum == Decimal('-10.00')
|
||||
o = Order.annotate_overpayments(Order.objects.all()).first()
|
||||
assert not o.is_underpaid
|
||||
assert o.is_overpaid
|
||||
assert not o.has_pending_refund
|
||||
assert not o.has_external_refund
|
||||
|
||||
def test_pending_order_overpaid(self):
|
||||
self.order.status = Order.STATUS_PENDING
|
||||
self.order.save()
|
||||
self.order.payments.create(
|
||||
amount=Decimal('56.00'),
|
||||
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||
provider='manual'
|
||||
)
|
||||
self.order.refunds.create(
|
||||
amount=Decimal('10.00'),
|
||||
state=OrderRefund.REFUND_STATE_DONE,
|
||||
provider='manual'
|
||||
)
|
||||
assert self.order.pending_sum == Decimal('0.00')
|
||||
o = Order.annotate_overpayments(Order.objects.all()).first()
|
||||
assert not o.is_underpaid
|
||||
assert o.is_overpaid
|
||||
assert not o.has_pending_refund
|
||||
assert not o.has_external_refund
|
||||
|
||||
|
||||
class ItemCategoryTest(TestCase):
|
||||
"""
|
||||
@@ -1130,7 +1286,7 @@ class CheckinListTestCase(TestCase):
|
||||
code='FOO', event=cls.event, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PAID,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=Decimal("30"), payment_provider='banktransfer', locale='en'
|
||||
total=Decimal("30"), locale='en'
|
||||
)
|
||||
OrderPosition.objects.create(
|
||||
order=o,
|
||||
@@ -1157,7 +1313,7 @@ class CheckinListTestCase(TestCase):
|
||||
code='FOO', event=cls.event, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=Decimal("30"), payment_provider='banktransfer', locale='en'
|
||||
total=Decimal("30"), locale='en'
|
||||
)
|
||||
op4 = OrderPosition.objects.create(
|
||||
order=o,
|
||||
|
||||
@@ -27,7 +27,7 @@ def order(event):
|
||||
code='FOO', event=event, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING, locale='en',
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=Decimal('46.00'), payment_provider='banktransfer'
|
||||
total=Decimal('46.00'),
|
||||
)
|
||||
tr19 = event.tax_rules.create(rate=Decimal('19.00'))
|
||||
ticket = Item.objects.create(event=event, name='Early-bird ticket', tax_rule=tr19,
|
||||
|
||||
@@ -13,13 +13,13 @@ from pretix.base.models import (
|
||||
CartPosition, Event, InvoiceAddress, Item, Order, OrderPosition, Organizer,
|
||||
)
|
||||
from pretix.base.models.items import SubEventItem
|
||||
from pretix.base.models.orders import OrderFee
|
||||
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
|
||||
from pretix.base.payment import FreeOrderProvider
|
||||
from pretix.base.reldate import RelativeDate, RelativeDateWrapper
|
||||
from pretix.base.services.invoices import generate_invoice
|
||||
from pretix.base.services.orders import (
|
||||
OrderChangeManager, OrderError, _create_order, expire_orders,
|
||||
mark_order_paid, send_download_reminders,
|
||||
send_download_reminders,
|
||||
)
|
||||
|
||||
|
||||
@@ -147,13 +147,13 @@ def test_expiring(event):
|
||||
code='FOO', event=event, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING, locale='en',
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=0, payment_provider='banktransfer'
|
||||
total=0,
|
||||
)
|
||||
o2 = Order.objects.create(
|
||||
code='FO2', event=event, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING, locale='en',
|
||||
datetime=now(), expires=now() - timedelta(days=10),
|
||||
total=12, payment_provider='banktransfer'
|
||||
total=12,
|
||||
)
|
||||
generate_invoice(o2)
|
||||
expire_orders(None)
|
||||
@@ -171,14 +171,16 @@ def test_expiring_paid_invoice(event):
|
||||
code='FO2', event=event, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING, locale='en',
|
||||
datetime=now(), expires=now() - timedelta(days=10),
|
||||
total=12, payment_provider='banktransfer'
|
||||
total=12,
|
||||
)
|
||||
generate_invoice(o2)
|
||||
expire_orders(None)
|
||||
o2 = Order.objects.get(id=o2.id)
|
||||
assert o2.status == Order.STATUS_EXPIRED
|
||||
assert o2.invoices.count() == 2
|
||||
mark_order_paid(o2)
|
||||
o2.payments.create(
|
||||
provider='manual', amount=o2.total
|
||||
).confirm()
|
||||
assert o2.invoices.count() == 3
|
||||
assert o2.invoices.last().is_cancellation is False
|
||||
|
||||
@@ -190,13 +192,13 @@ def test_expiring_auto_disabled(event):
|
||||
code='FOO', event=event, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=0, payment_provider='banktransfer'
|
||||
total=0,
|
||||
)
|
||||
o2 = Order.objects.create(
|
||||
code='FO2', event=event, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() - timedelta(days=10),
|
||||
total=0, payment_provider='banktransfer'
|
||||
total=0,
|
||||
)
|
||||
expire_orders(None)
|
||||
o1 = Order.objects.get(id=o1.id)
|
||||
@@ -219,7 +221,7 @@ class DownloadReminderTests(TestCase):
|
||||
status=Order.STATUS_PAID, locale='en',
|
||||
datetime=now(),
|
||||
expires=now() + timedelta(days=10),
|
||||
total=Decimal('46.00'), payment_provider='banktransfer'
|
||||
total=Decimal('46.00'),
|
||||
)
|
||||
self.ticket = Item.objects.create(event=self.event, name='Early-bird ticket',
|
||||
default_price=Decimal('23.00'), admission=True)
|
||||
@@ -264,7 +266,10 @@ class OrderChangeManagerTests(TestCase):
|
||||
code='FOO', event=self.event, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING, locale='en',
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=Decimal('46.00'), payment_provider='banktransfer'
|
||||
total=Decimal('46.00'),
|
||||
)
|
||||
self.order.payments.create(
|
||||
provider='banktransfer', state=OrderPayment.PAYMENT_STATE_CREATED, amount=self.order.total
|
||||
)
|
||||
self.tr7 = self.event.tax_rules.create(rate=Decimal('7.00'))
|
||||
self.tr19 = self.event.tax_rules.create(rate=Decimal('19.00'))
|
||||
@@ -593,10 +598,9 @@ class OrderChangeManagerTests(TestCase):
|
||||
self.order.status = Order.STATUS_PAID
|
||||
self.order.save()
|
||||
self.ocm.change_item(self.op1, self.shirt, None)
|
||||
with self.assertRaises(OrderError):
|
||||
self.ocm.commit()
|
||||
self.ocm.commit()
|
||||
self.op1.refresh_from_db()
|
||||
assert self.op1.item == self.ticket
|
||||
assert self.op1.item == self.shirt
|
||||
|
||||
def test_change_price_to_free_marked_as_paid(self):
|
||||
self.ocm.change_price(self.op1, Decimal('0.00'))
|
||||
@@ -605,7 +609,7 @@ class OrderChangeManagerTests(TestCase):
|
||||
self.order.refresh_from_db()
|
||||
assert self.order.total == 0
|
||||
assert self.order.status == Order.STATUS_PAID
|
||||
assert self.order.payment_provider == 'free'
|
||||
assert self.order.payments.last().provider == 'free'
|
||||
|
||||
def test_change_paid_same_price(self):
|
||||
self.order.status = Order.STATUS_PAID
|
||||
@@ -620,12 +624,26 @@ class OrderChangeManagerTests(TestCase):
|
||||
self.order.status = Order.STATUS_PAID
|
||||
self.order.save()
|
||||
self.ocm.change_price(self.op1, Decimal('5.00'))
|
||||
with self.assertRaises(OrderError):
|
||||
self.ocm.commit()
|
||||
self.ocm.commit()
|
||||
self.order.refresh_from_db()
|
||||
assert self.order.total == 46
|
||||
assert self.order.total == Decimal('28.00')
|
||||
assert self.order.status == Order.STATUS_PAID
|
||||
|
||||
def test_change_paid_to_pending(self):
|
||||
self.order.status = Order.STATUS_PAID
|
||||
self.order.save()
|
||||
self.order.payments.create(
|
||||
provider='manual',
|
||||
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||
amount=self.order.total,
|
||||
)
|
||||
self.ocm.change_price(self.op1, Decimal('25.00'))
|
||||
self.ocm.commit()
|
||||
self.order.refresh_from_db()
|
||||
assert self.order.total == Decimal('48.00')
|
||||
assert self.order.pending_sum == Decimal('2.00')
|
||||
assert self.order.status == Order.STATUS_PENDING
|
||||
|
||||
def test_add_item_quota_required(self):
|
||||
self.quota.delete()
|
||||
with self.assertRaises(OrderError):
|
||||
@@ -775,10 +793,10 @@ class OrderChangeManagerTests(TestCase):
|
||||
assert fee.tax_rate == Decimal('19.00')
|
||||
assert fee.tax_value == Decimal('0.05')
|
||||
|
||||
self.ocm = OrderChangeManager(self.order, None)
|
||||
ia = self._enable_reverse_charge()
|
||||
self.ocm.recalculate_taxes()
|
||||
self.ocm.commit()
|
||||
self.ocm = OrderChangeManager(self.order, None)
|
||||
ops = list(self.order.positions.all())
|
||||
for op in ops:
|
||||
assert op.price == Decimal('21.50')
|
||||
@@ -794,6 +812,7 @@ class OrderChangeManagerTests(TestCase):
|
||||
ia.vat_id_validated = False
|
||||
ia.save()
|
||||
|
||||
self.ocm = OrderChangeManager(self.order, None)
|
||||
self.ocm.recalculate_taxes()
|
||||
self.ocm.commit()
|
||||
ops = list(self.order.positions.all())
|
||||
@@ -870,6 +889,11 @@ class OrderChangeManagerTests(TestCase):
|
||||
def test_split_paid_no_payment_fees(self):
|
||||
self.order.status = Order.STATUS_PAID
|
||||
self.order.save()
|
||||
self.order.payments.create(
|
||||
provider='manual',
|
||||
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||
amount=self.order.total,
|
||||
)
|
||||
|
||||
# Split
|
||||
self.ocm.split(self.op2)
|
||||
@@ -880,6 +904,11 @@ class OrderChangeManagerTests(TestCase):
|
||||
# First order
|
||||
assert self.order.total == Decimal('23.00')
|
||||
assert not self.order.fees.exists()
|
||||
assert self.order.pending_sum == Decimal('0.00')
|
||||
r = self.order.refunds.last()
|
||||
assert r.provider == 'offsetting'
|
||||
assert r.amount == Decimal('23.00')
|
||||
assert r.state == OrderRefund.REFUND_STATE_DONE
|
||||
|
||||
# New order
|
||||
assert self.op2.order != self.order
|
||||
@@ -888,6 +917,11 @@ class OrderChangeManagerTests(TestCase):
|
||||
assert o2.status == Order.STATUS_PAID
|
||||
assert o2.positions.count() == 1
|
||||
assert o2.fees.count() == 0
|
||||
assert o2.pending_sum == Decimal('0.00')
|
||||
p = o2.payments.last()
|
||||
assert p.provider == 'offsetting'
|
||||
assert p.amount == Decimal('23.00')
|
||||
assert p.state == OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
|
||||
def test_split_invoice_address(self):
|
||||
ia = InvoiceAddress.objects.create(
|
||||
@@ -1026,6 +1060,9 @@ class OrderChangeManagerTests(TestCase):
|
||||
|
||||
self.order.status = Order.STATUS_PAID
|
||||
self.order.save()
|
||||
payment = self.order.payments.first()
|
||||
payment.state = OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
payment.save()
|
||||
|
||||
# Split
|
||||
self.ocm.split(self.op2)
|
||||
|
||||
@@ -150,7 +150,7 @@ def test_availability_date_order_relative_subevents(event):
|
||||
code='FOO', event=event, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() + datetime.timedelta(days=10),
|
||||
total=Decimal('46.00'), payment_provider='dummtest'
|
||||
total=Decimal('46.00'),
|
||||
)
|
||||
OrderPosition.objects.create(
|
||||
order=order, item=ticket, variation=None, subevent=se1,
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.utils.timezone import now
|
||||
|
||||
from pretix.base.models import (
|
||||
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, Order,
|
||||
OrderPosition, Organizer, QuestionAnswer,
|
||||
OrderPayment, OrderPosition, Organizer, QuestionAnswer,
|
||||
)
|
||||
from pretix.base.services.invoices import generate_invoice, invoice_pdf_task
|
||||
from pretix.base.services.tickets import generate, generate_order
|
||||
@@ -45,7 +45,7 @@ def order(event, item):
|
||||
code='FOO', event=event, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=14, payment_provider='banktransfer', locale='en'
|
||||
total=14, locale='en'
|
||||
)
|
||||
event.settings.set('attendee_names_asked', True)
|
||||
event.settings.set('locales', ['en', 'de'])
|
||||
@@ -295,12 +295,12 @@ def test_cached_tickets(event, order):
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_payment_info_shredder(event, order):
|
||||
order.payment_info = json.dumps({
|
||||
order.payments.create(info=json.dumps({
|
||||
'reference': 'Verwendungszweck 1',
|
||||
'date': '2018-05-01',
|
||||
'payer': 'Hans',
|
||||
'trans_id': 12
|
||||
})
|
||||
}), provider='banktransfer', amount=order.total, state=OrderPayment.PAYMENT_STATE_PENDING)
|
||||
order.save()
|
||||
|
||||
s = PaymentInfoShredder(event)
|
||||
@@ -308,7 +308,7 @@ def test_payment_info_shredder(event, order):
|
||||
s.shred_data()
|
||||
|
||||
order.refresh_from_db()
|
||||
assert json.loads(order.payment_info) == {
|
||||
assert order.payments.first().info_data == {
|
||||
'_shredded': True,
|
||||
'reference': '█',
|
||||
'date': '2018-05-01',
|
||||
|
||||
@@ -38,7 +38,7 @@ def dashboard_env():
|
||||
code='FOO', event=event, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PAID,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=33, payment_provider='banktransfer', locale='en'
|
||||
total=33, locale='en'
|
||||
)
|
||||
OrderPosition.objects.create(
|
||||
order=order_paid,
|
||||
@@ -70,7 +70,7 @@ def test_dashboard_pending_not_count(dashboard_env):
|
||||
code='FOO', event=dashboard_env[0], email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=23, payment_provider='banktransfer', locale='en'
|
||||
total=23, locale='en'
|
||||
)
|
||||
OrderPosition.objects.create(
|
||||
order=order_pending,
|
||||
@@ -122,25 +122,25 @@ def checkin_list_env():
|
||||
code='PENDING', event=event, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=23, payment_provider='banktransfer', locale='en'
|
||||
total=23, locale='en'
|
||||
)
|
||||
order_a1 = Order.objects.create(
|
||||
code='A1', event=event, email='a1dummy@dummy.test',
|
||||
status=Order.STATUS_PAID,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=33, payment_provider='banktransfer', locale='en'
|
||||
total=33, locale='en'
|
||||
)
|
||||
order_a2 = Order.objects.create(
|
||||
code='A2', event=event, email='a2dummy@dummy.test',
|
||||
status=Order.STATUS_PAID,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=23, payment_provider='banktransfer', locale='en'
|
||||
total=23, locale='en'
|
||||
)
|
||||
order_a3 = Order.objects.create(
|
||||
code='A3', event=event, email='a3dummy@dummy.test',
|
||||
status=Order.STATUS_PAID,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=23, payment_provider='banktransfer', locale='en'
|
||||
total=23, locale='en'
|
||||
)
|
||||
|
||||
# order position
|
||||
@@ -298,19 +298,19 @@ def checkin_list_with_addon_env():
|
||||
code='PENDING', event=event, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=23, payment_provider='banktransfer', locale='en'
|
||||
total=23, locale='en'
|
||||
)
|
||||
order_a1 = Order.objects.create(
|
||||
code='A1', event=event, email='a1dummy@dummy.test',
|
||||
status=Order.STATUS_PAID,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=33, payment_provider='banktransfer', locale='en'
|
||||
total=33, locale='en'
|
||||
)
|
||||
order_a2 = Order.objects.create(
|
||||
code='A2', event=event, email='a2dummy@dummy.test',
|
||||
status=Order.STATUS_PAID,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=23, payment_provider='banktransfer', locale='en'
|
||||
total=23, locale='en'
|
||||
)
|
||||
|
||||
# order position
|
||||
|
||||
@@ -838,7 +838,7 @@ class SubEventsTest(SoupTest):
|
||||
code='FOO', event=self.event1, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() + datetime.timedelta(days=10),
|
||||
total=14, payment_provider='banktransfer', locale='en'
|
||||
total=14, locale='en'
|
||||
)
|
||||
OrderPosition.objects.create(
|
||||
order=o,
|
||||
@@ -1180,7 +1180,7 @@ class SubEventsTest(SoupTest):
|
||||
code='FOO', event=self.event1, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() + datetime.timedelta(days=10),
|
||||
total=14, payment_provider='banktransfer', locale='en'
|
||||
total=14, locale='en'
|
||||
)
|
||||
OrderPosition.objects.create(
|
||||
order=o,
|
||||
@@ -1266,7 +1266,7 @@ class EventDeletionTest(SoupTest):
|
||||
code='FOO', event=self.event1, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now(),
|
||||
total=14, payment_provider='banktransfer', locale='en'
|
||||
total=14, locale='en'
|
||||
)
|
||||
self.post_doc('/control/event/ccc/30c3/delete/', {
|
||||
'user_pw': 'dummy',
|
||||
|
||||
@@ -185,7 +185,7 @@ class QuestionsTest(ItemFormTest):
|
||||
o = Order.objects.create(code='FOO', event=self.event1, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING, datetime=now(),
|
||||
expires=now() + datetime.timedelta(days=10),
|
||||
total=14, payment_provider='banktransfer', locale='en')
|
||||
total=14, locale='en')
|
||||
op = OrderPosition.objects.create(order=o, item=item1, variation=None, price=Decimal("14"),
|
||||
attendee_name="Peter")
|
||||
op.answers.create(question=c, answer='42')
|
||||
@@ -407,7 +407,7 @@ class ItemsTest(ItemFormTest):
|
||||
code='FOO', event=self.event1, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() + datetime.timedelta(days=10),
|
||||
total=14, payment_provider='banktransfer', locale='en'
|
||||
total=14, locale='en'
|
||||
)
|
||||
OrderPosition.objects.create(
|
||||
order=o,
|
||||
|
||||
@@ -3,15 +3,18 @@ from decimal import Decimal
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
from django.core import mail
|
||||
from django.utils.timezone import now
|
||||
from django_countries.fields import Country
|
||||
from tests.base import SoupTest
|
||||
from tests.plugins.stripe.test_provider import MockedCharge
|
||||
|
||||
from pretix.base.models import (
|
||||
Event, InvoiceAddress, Item, Order, OrderPosition, Organizer, Question,
|
||||
QuestionAnswer, Quota, Team, User,
|
||||
Event, InvoiceAddress, Item, Order, OrderPayment, OrderPosition,
|
||||
OrderRefund, Organizer, Question, QuestionAnswer, Quota, Team, User,
|
||||
)
|
||||
from pretix.base.payment import PaymentException
|
||||
from pretix.base.services.invoices import (
|
||||
generate_cancellation, generate_invoice,
|
||||
)
|
||||
@@ -22,7 +25,7 @@ def env():
|
||||
o = Organizer.objects.create(name='Dummy', slug='dummy')
|
||||
event = Event.objects.create(
|
||||
organizer=o, name='Dummy', slug='dummy',
|
||||
date_from=now(), plugins='pretix.plugins.banktransfer,tests.testdummy'
|
||||
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.stripe,tests.testdummy'
|
||||
)
|
||||
event.settings.set('ticketoutput_testdummy__enabled', True)
|
||||
user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
|
||||
@@ -33,7 +36,10 @@ def env():
|
||||
code='FOO', event=event, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=14, payment_provider='banktransfer', locale='en'
|
||||
total=14, locale='en'
|
||||
)
|
||||
o.payments.create(
|
||||
amount=o.total, provider='banktransfer', state=OrderPayment.PAYMENT_STATE_PENDING
|
||||
)
|
||||
ticket = Item.objects.create(event=event, name='Early-bird ticket',
|
||||
category=None, default_price=23,
|
||||
@@ -212,7 +218,7 @@ def test_order_transition_to_paid_expired_quota_left(client, env):
|
||||
|
||||
(Order.STATUS_PAID, Order.STATUS_PENDING, True),
|
||||
(Order.STATUS_PAID, Order.STATUS_CANCELED, False),
|
||||
(Order.STATUS_PAID, Order.STATUS_REFUNDED, True),
|
||||
(Order.STATUS_PAID, Order.STATUS_REFUNDED, False),
|
||||
(Order.STATUS_PAID, Order.STATUS_EXPIRED, False),
|
||||
|
||||
(Order.STATUS_PENDING, Order.STATUS_CANCELED, True),
|
||||
@@ -688,7 +694,7 @@ class OrderChangeTests(SoupTest):
|
||||
code='FOO', event=self.event, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=Decimal('46.00'), payment_provider='banktransfer'
|
||||
total=Decimal('46.00'),
|
||||
)
|
||||
self.tr7 = self.event.tax_rules.create(rate=Decimal('7.00'))
|
||||
self.tr19 = self.event.tax_rules.create(rate=Decimal('19.00'))
|
||||
@@ -944,3 +950,530 @@ def test_check_vatid_unavailable(client, env):
|
||||
assert 'alert-danger' in response.rendered_content
|
||||
ia.refresh_from_db()
|
||||
assert not ia.vat_id_validated
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cancel_payment(client, env):
|
||||
p = env[2].payments.last()
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
response = client.post('/control/event/dummy/dummy/orders/FOO/payments/{}/cancel'.format(p.pk), {}, follow=True)
|
||||
assert 'alert-success' in response.rendered_content
|
||||
p.refresh_from_db()
|
||||
assert p.state == OrderPayment.PAYMENT_STATE_CANCELED
|
||||
response = client.post('/control/event/dummy/dummy/orders/FOO/payments/{}/cancel'.format(p.pk), {}, follow=True)
|
||||
assert 'alert-danger' in response.rendered_content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cancel_refund(client, env):
|
||||
r = env[2].refunds.create(
|
||||
provider='stripe',
|
||||
state='transit',
|
||||
source='admin',
|
||||
amount=Decimal('23.00'),
|
||||
execution_date=now(),
|
||||
)
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
response = client.post('/control/event/dummy/dummy/orders/FOO/refunds/{}/cancel'.format(r.pk), {}, follow=True)
|
||||
assert 'alert-success' in response.rendered_content
|
||||
r.refresh_from_db()
|
||||
assert r.state == OrderRefund.REFUND_STATE_CANCELED
|
||||
r.state = OrderRefund.REFUND_STATE_DONE
|
||||
r.save()
|
||||
response = client.post('/control/event/dummy/dummy/orders/FOO/refunds/{}/cancel'.format(r.pk), {}, follow=True)
|
||||
assert 'alert-danger' in response.rendered_content
|
||||
r.refresh_from_db()
|
||||
assert r.state == OrderRefund.REFUND_STATE_DONE
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_process_refund(client, env):
|
||||
r = env[2].refunds.create(
|
||||
provider='stripe',
|
||||
state='external',
|
||||
source='external',
|
||||
amount=Decimal('23.00'),
|
||||
execution_date=now(),
|
||||
)
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
response = client.post('/control/event/dummy/dummy/orders/FOO/refunds/{}/process'.format(r.pk), {}, follow=True)
|
||||
assert 'alert-success' in response.rendered_content
|
||||
r.refresh_from_db()
|
||||
assert r.state == OrderRefund.REFUND_STATE_DONE
|
||||
env[2].refresh_from_db()
|
||||
assert env[2].status == Order.STATUS_PENDING
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_process_refund_invalid_state(client, env):
|
||||
r = env[2].refunds.create(
|
||||
provider='stripe',
|
||||
state='canceled',
|
||||
source='external',
|
||||
amount=Decimal('23.00'),
|
||||
execution_date=now(),
|
||||
)
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
response = client.post('/control/event/dummy/dummy/orders/FOO/refunds/{}/process'.format(r.pk), {}, follow=True)
|
||||
assert 'alert-danger' in response.rendered_content
|
||||
r.refresh_from_db()
|
||||
assert r.state == OrderRefund.REFUND_STATE_CANCELED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_process_refund_mark_refunded(client, env):
|
||||
r = env[2].refunds.create(
|
||||
provider='stripe',
|
||||
state='external',
|
||||
source='external',
|
||||
amount=Decimal('23.00'),
|
||||
execution_date=now(),
|
||||
)
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
response = client.post('/control/event/dummy/dummy/orders/FOO/refunds/{}/process'.format(r.pk), {'action': 'r'},
|
||||
follow=True)
|
||||
assert 'alert-success' in response.rendered_content
|
||||
r.refresh_from_db()
|
||||
assert r.state == OrderRefund.REFUND_STATE_DONE
|
||||
env[2].refresh_from_db()
|
||||
assert env[2].status == Order.STATUS_REFUNDED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_done_refund(client, env):
|
||||
r = env[2].refunds.create(
|
||||
provider='stripe',
|
||||
state='transit',
|
||||
source='admin',
|
||||
amount=Decimal('23.00'),
|
||||
execution_date=now(),
|
||||
)
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
response = client.post('/control/event/dummy/dummy/orders/FOO/refunds/{}/done'.format(r.pk), {}, follow=True)
|
||||
assert 'alert-success' in response.rendered_content
|
||||
r.refresh_from_db()
|
||||
assert r.state == OrderRefund.REFUND_STATE_DONE
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_done_refund_invalid_state(client, env):
|
||||
r = env[2].refunds.create(
|
||||
provider='stripe',
|
||||
state='external',
|
||||
source='external',
|
||||
amount=Decimal('23.00'),
|
||||
execution_date=now(),
|
||||
)
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
response = client.post('/control/event/dummy/dummy/orders/FOO/refunds/{}/done'.format(r.pk), {}, follow=True)
|
||||
assert 'alert-danger' in response.rendered_content
|
||||
r.refresh_from_db()
|
||||
assert r.state == OrderRefund.REFUND_STATE_EXTERNAL
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_confirm_payment(client, env):
|
||||
p = env[2].payments.last()
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
response = client.post('/control/event/dummy/dummy/orders/FOO/payments/{}/confirm'.format(p.pk), {}, follow=True)
|
||||
assert 'alert-success' in response.rendered_content
|
||||
p.refresh_from_db()
|
||||
assert p.state == OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
env[2].refresh_from_db()
|
||||
assert env[2].status == Order.STATUS_PAID
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_confirm_payment_invalid_state(client, env):
|
||||
p = env[2].payments.last()
|
||||
p.state = OrderPayment.PAYMENT_STATE_FAILED
|
||||
p.save()
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
response = client.post('/control/event/dummy/dummy/orders/FOO/payments/{}/confirm'.format(p.pk), {}, follow=True)
|
||||
assert 'alert-danger' in response.rendered_content
|
||||
p.refresh_from_db()
|
||||
assert p.state == OrderPayment.PAYMENT_STATE_FAILED
|
||||
env[2].refresh_from_db()
|
||||
assert env[2].status == Order.STATUS_PENDING
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_confirm_payment_partal_amount(client, env):
|
||||
p = env[2].payments.last()
|
||||
p.amount -= Decimal(5.00)
|
||||
p.save()
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
response = client.post('/control/event/dummy/dummy/orders/FOO/payments/{}/confirm'.format(p.pk), {}, follow=True)
|
||||
assert 'alert-success' in response.rendered_content
|
||||
p.refresh_from_db()
|
||||
assert p.state == OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
env[2].refresh_from_db()
|
||||
assert env[2].status == Order.STATUS_PENDING
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refund_paid_order_fully_mark_as_refunded(client, env):
|
||||
p = env[2].payments.last()
|
||||
p.confirm()
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
response = client.get('/control/event/dummy/dummy/orders/FOO/refund')
|
||||
doc = BeautifulSoup(response.content, "lxml")
|
||||
assert doc.select("input[name$=partial_amount]")[0]["value"] == "14.00"
|
||||
client.post('/control/event/dummy/dummy/orders/FOO/refund', {
|
||||
'start-partial_amount': '14.00',
|
||||
'start-mode': 'full',
|
||||
'start-action': 'mark_refunded'
|
||||
}, follow=True)
|
||||
client.post('/control/event/dummy/dummy/orders/FOO/refund', {
|
||||
'start-partial_amount': '14.00',
|
||||
'start-mode': 'full',
|
||||
'start-action': 'mark_refunded',
|
||||
'refund-manual': '14.00',
|
||||
'manual_state': 'done',
|
||||
'perform': 'on'
|
||||
}, follow=True)
|
||||
p.refresh_from_db()
|
||||
assert p.state == OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
env[2].refresh_from_db()
|
||||
r = env[2].refunds.last()
|
||||
assert r.provider == "manual"
|
||||
assert r.state == OrderRefund.REFUND_STATE_DONE
|
||||
assert r.amount == Decimal('14.00')
|
||||
assert env[2].status == Order.STATUS_REFUNDED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refund_paid_order_fully_mark_as_pending(client, env):
|
||||
p = env[2].payments.last()
|
||||
p.confirm()
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
response = client.get('/control/event/dummy/dummy/orders/FOO/refund')
|
||||
doc = BeautifulSoup(response.content, "lxml")
|
||||
assert doc.select("input[name$=partial_amount]")[0]["value"] == "14.00"
|
||||
client.post('/control/event/dummy/dummy/orders/FOO/refund', {
|
||||
'start-partial_amount': '14.00',
|
||||
'start-mode': 'full',
|
||||
'start-action': 'mark_pending',
|
||||
'refund-manual': '14.00',
|
||||
'manual_state': 'pending',
|
||||
'perform': 'on'
|
||||
}, follow=True)
|
||||
p.refresh_from_db()
|
||||
assert p.state == OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
env[2].refresh_from_db()
|
||||
r = env[2].refunds.last()
|
||||
assert r.provider == "manual"
|
||||
assert r.state == OrderRefund.REFUND_STATE_CREATED
|
||||
assert r.amount == Decimal('14.00')
|
||||
assert env[2].status == Order.STATUS_PENDING
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refund_paid_order_partially_mark_as_pending(client, env):
|
||||
p = env[2].payments.last()
|
||||
p.confirm()
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
response = client.get('/control/event/dummy/dummy/orders/FOO/refund')
|
||||
doc = BeautifulSoup(response.content, "lxml")
|
||||
assert doc.select("input[name$=partial_amount]")[0]["value"] == "14.00"
|
||||
client.post('/control/event/dummy/dummy/orders/FOO/refund', {
|
||||
'start-partial_amount': '7.00',
|
||||
'start-mode': 'partial',
|
||||
'start-action': 'mark_pending'
|
||||
}, follow=True)
|
||||
client.post('/control/event/dummy/dummy/orders/FOO/refund', {
|
||||
'start-partial_amount': '7.00',
|
||||
'start-mode': 'partial',
|
||||
'start-action': 'mark_pending',
|
||||
'refund-manual': '7.00',
|
||||
'manual_state': 'pending',
|
||||
'perform': 'on'
|
||||
}, follow=True)
|
||||
p.refresh_from_db()
|
||||
assert p.state == OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
env[2].refresh_from_db()
|
||||
r = env[2].refunds.last()
|
||||
assert r.provider == "manual"
|
||||
assert r.state == OrderRefund.REFUND_STATE_CREATED
|
||||
assert r.amount == Decimal('7.00')
|
||||
assert env[2].status == Order.STATUS_PENDING
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refund_propose_lower_payment(client, env):
|
||||
p = env[2].payments.last()
|
||||
p.amount = Decimal('8.00')
|
||||
p.confirm()
|
||||
p2 = env[2].payments.create(
|
||||
amount=Decimal('6.00'), provider='stripe', state=OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
)
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
client.get('/control/event/dummy/dummy/orders/FOO/refund')
|
||||
response = client.post('/control/event/dummy/dummy/orders/FOO/refund', {
|
||||
'start-partial_amount': '7.00',
|
||||
'start-mode': 'partial',
|
||||
'start-action': 'mark_pending'
|
||||
}, follow=True)
|
||||
doc = BeautifulSoup(response.content, "lxml")
|
||||
assert doc.select("input[name=refund-{}]".format(p2.pk))[0]['value'] == '6.00'
|
||||
assert doc.select("input[name=refund-manual]".format(p2.pk))[0]['value'] == '1.00'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refund_propose_equal_payment(client, env):
|
||||
p = env[2].payments.last()
|
||||
p.amount = Decimal('7.00')
|
||||
p.confirm()
|
||||
p2 = env[2].payments.create(
|
||||
amount=Decimal('7.00'), provider='stripe', state=OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
)
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
client.get('/control/event/dummy/dummy/orders/FOO/refund')
|
||||
response = client.post('/control/event/dummy/dummy/orders/FOO/refund', {
|
||||
'start-partial_amount': '7.00',
|
||||
'start-mode': 'partial',
|
||||
'start-action': 'mark_pending'
|
||||
}, follow=True)
|
||||
doc = BeautifulSoup(response.content, "lxml")
|
||||
assert doc.select("input[name=refund-{}]".format(p2.pk))[0]['value'] == '7.00'
|
||||
assert doc.select("input[name=refund-manual]".format(p2.pk))[0]['value'] == '0.00'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refund_propose_higher_payment(client, env):
|
||||
p = env[2].payments.last()
|
||||
p.amount = Decimal('6.00')
|
||||
p.confirm()
|
||||
p2 = env[2].payments.create(
|
||||
amount=Decimal('8.00'), provider='stripe', state=OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
)
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
client.get('/control/event/dummy/dummy/orders/FOO/refund')
|
||||
response = client.post('/control/event/dummy/dummy/orders/FOO/refund', {
|
||||
'start-partial_amount': '7.00',
|
||||
'start-mode': 'partial',
|
||||
'start-action': 'mark_pending'
|
||||
}, follow=True)
|
||||
doc = BeautifulSoup(response.content, "lxml")
|
||||
assert doc.select("input[name=refund-{}]".format(p2.pk))[0]['value'] == '7.00'
|
||||
assert doc.select("input[name=refund-manual]".format(p2.pk))[0]['value'] == '0.00'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refund_amount_does_not_match_or_invalid(client, env):
|
||||
p = env[2].payments.last()
|
||||
p.confirm()
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
resp = client.post('/control/event/dummy/dummy/orders/FOO/refund', {
|
||||
'start-partial_amount': '7.00',
|
||||
'start-mode': 'partial',
|
||||
'start-action': 'mark_pending',
|
||||
'refund-manual': '4.00',
|
||||
'refund-{}'.format(p.pk): '4.00',
|
||||
'manual_state': 'pending',
|
||||
'perform': 'on'
|
||||
}, follow=True)
|
||||
assert b'alert-danger' in resp.content
|
||||
assert b'do not match the' in resp.content
|
||||
resp = client.post('/control/event/dummy/dummy/orders/FOO/refund', {
|
||||
'start-partial_amount': '15.00',
|
||||
'start-mode': 'partial',
|
||||
'start-action': 'mark_pending',
|
||||
'refund-manual': '0.00',
|
||||
'refund-{}'.format(p.pk): '15.00',
|
||||
'manual_state': 'pending',
|
||||
'perform': 'on'
|
||||
}, follow=True)
|
||||
assert b'alert-danger' in resp.content
|
||||
assert b'The refund amount needs to be positive' in resp.content
|
||||
resp = client.post('/control/event/dummy/dummy/orders/FOO/refund', {
|
||||
'start-partial_amount': '7.00',
|
||||
'start-mode': 'partial',
|
||||
'start-action': 'mark_pending',
|
||||
'refund-manual': '-3.00',
|
||||
'refund-{}'.format(p.pk): '10.00',
|
||||
'manual_state': 'pending',
|
||||
'perform': 'on'
|
||||
}, follow=True)
|
||||
assert b'alert-danger' in resp.content
|
||||
assert b'do not match the' in resp.content
|
||||
resp = client.post('/control/event/dummy/dummy/orders/FOO/refund', {
|
||||
'start-partial_amount': '7.00',
|
||||
'start-mode': 'partial',
|
||||
'start-action': 'mark_pending',
|
||||
'refund-manual': 'AA',
|
||||
'refund-{}'.format(p.pk): '10.00',
|
||||
'manual_state': 'pending',
|
||||
'perform': 'on'
|
||||
}, follow=True)
|
||||
assert b'alert-danger' in resp.content
|
||||
assert b'invalid number' in resp.content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refund_paid_order_automatically_failed(client, env, monkeypatch):
|
||||
p = env[2].payments.last()
|
||||
p.provider = 'stripe'
|
||||
p.info_data = {
|
||||
'id': 'foo'
|
||||
}
|
||||
p.confirm()
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
|
||||
def charge_retr(*args, **kwargs):
|
||||
def refund_create(amount):
|
||||
raise PaymentException('This failed.')
|
||||
|
||||
c = MockedCharge()
|
||||
c.refunds.create = refund_create
|
||||
return c
|
||||
|
||||
monkeypatch.setattr("stripe.Charge.retrieve", charge_retr)
|
||||
|
||||
r = client.post('/control/event/dummy/dummy/orders/FOO/refund', {
|
||||
'start-partial_amount': '7.00',
|
||||
'start-mode': 'partial',
|
||||
'start-action': 'mark_pending',
|
||||
'refund-{}'.format(p.pk): '7.00',
|
||||
'manual_state': 'pending',
|
||||
'perform': 'on'
|
||||
}, follow=True)
|
||||
assert b'This failed.' in r.content
|
||||
p.refresh_from_db()
|
||||
assert p.state == OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
env[2].refresh_from_db()
|
||||
r = env[2].refunds.last()
|
||||
assert r.provider == "stripe"
|
||||
assert r.state == OrderRefund.REFUND_STATE_FAILED
|
||||
assert r.amount == Decimal('7.00')
|
||||
assert env[2].status == Order.STATUS_PAID
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refund_paid_order_automatically(client, env, monkeypatch):
|
||||
p = env[2].payments.last()
|
||||
p.provider = 'stripe'
|
||||
p.info_data = {
|
||||
'id': 'foo'
|
||||
}
|
||||
p.confirm()
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
|
||||
def charge_retr(*args, **kwargs):
|
||||
def refund_create(amount):
|
||||
pass
|
||||
|
||||
c = MockedCharge()
|
||||
c.refunds.create = refund_create
|
||||
return c
|
||||
|
||||
monkeypatch.setattr("stripe.Charge.retrieve", charge_retr)
|
||||
|
||||
client.post('/control/event/dummy/dummy/orders/FOO/refund', {
|
||||
'start-partial_amount': '7.00',
|
||||
'start-mode': 'partial',
|
||||
'start-action': 'mark_pending',
|
||||
'refund-{}'.format(p.pk): '7.00',
|
||||
'manual_state': 'pending',
|
||||
'perform': 'on'
|
||||
}, follow=True)
|
||||
p.refresh_from_db()
|
||||
assert p.state == OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
env[2].refresh_from_db()
|
||||
r = env[2].refunds.last()
|
||||
assert r.provider == "stripe"
|
||||
assert r.state == OrderRefund.REFUND_STATE_DONE
|
||||
assert r.amount == Decimal('7.00')
|
||||
assert env[2].status == Order.STATUS_PENDING
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refund_paid_order_offsetting_to_unknown(client, env):
|
||||
p = env[2].payments.last()
|
||||
p.confirm()
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
|
||||
r = client.post('/control/event/dummy/dummy/orders/FOO/refund', {
|
||||
'start-partial_amount': '5.00',
|
||||
'start-mode': 'partial',
|
||||
'start-action': 'mark_pending',
|
||||
'refund-offsetting': '5.00',
|
||||
'order-offsetting': 'BAZ',
|
||||
'manual_state': 'pending',
|
||||
'perform': 'on'
|
||||
}, follow=True)
|
||||
assert b'alert-danger' in r.content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refund_paid_order_offsetting(client, env):
|
||||
p = env[2].payments.last()
|
||||
p.confirm()
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
o = Order.objects.create(
|
||||
code='BAZ', event=env[0], email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=5, locale='en'
|
||||
)
|
||||
|
||||
client.post('/control/event/dummy/dummy/orders/FOO/refund', {
|
||||
'start-partial_amount': '5.00',
|
||||
'start-mode': 'partial',
|
||||
'start-action': 'mark_pending',
|
||||
'refund-offsetting': '5.00',
|
||||
'order-offsetting': 'BAZ',
|
||||
'manual_state': 'pending',
|
||||
'perform': 'on'
|
||||
}, follow=True)
|
||||
p.refresh_from_db()
|
||||
assert p.state == OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
env[2].refresh_from_db()
|
||||
r = env[2].refunds.last()
|
||||
assert r.provider == "offsetting"
|
||||
assert r.state == OrderRefund.REFUND_STATE_DONE
|
||||
assert r.amount == Decimal('5.00')
|
||||
assert env[2].status == Order.STATUS_PENDING
|
||||
o.refresh_from_db()
|
||||
assert o.status == Order.STATUS_PAID
|
||||
p2 = o.payments.first()
|
||||
assert p2.provider == "offsetting"
|
||||
assert p2.amount == Decimal('5.00')
|
||||
assert p2.state == OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refund_list(client, env):
|
||||
env[2].refunds.create(
|
||||
provider='banktransfer',
|
||||
state='done',
|
||||
source='admin',
|
||||
amount=Decimal('23.00'),
|
||||
execution_date=now(),
|
||||
)
|
||||
env[2].refunds.create(
|
||||
provider='manual',
|
||||
state='created',
|
||||
source='admin',
|
||||
amount=Decimal('23.00'),
|
||||
execution_date=now(),
|
||||
)
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
response = client.get('/control/event/dummy/dummy/orders/refunds/')
|
||||
assert 'R-1' not in response.rendered_content
|
||||
assert 'R-2' in response.rendered_content
|
||||
response = client.get('/control/event/dummy/dummy/orders/refunds/?status=all')
|
||||
assert 'R-1' in response.rendered_content
|
||||
assert 'R-2' in response.rendered_content
|
||||
response = client.get('/control/event/dummy/dummy/orders/refunds/?status=created')
|
||||
assert 'R-1' not in response.rendered_content
|
||||
assert 'R-2' in response.rendered_content
|
||||
response = client.get('/control/event/dummy/dummy/orders/refunds/?status=done')
|
||||
assert 'R-1' in response.rendered_content
|
||||
assert 'R-2' not in response.rendered_content
|
||||
response = client.get('/control/event/dummy/dummy/orders/refunds/?status=all&provider=manual')
|
||||
assert 'R-1' not in response.rendered_content
|
||||
assert 'R-2' in response.rendered_content
|
||||
response = client.get('/control/event/dummy/dummy/orders/refunds/?status=all&provider=banktransfer')
|
||||
assert 'R-1' in response.rendered_content
|
||||
assert 'R-2' not in response.rendered_content
|
||||
|
||||
@@ -18,7 +18,7 @@ def env():
|
||||
code='FOO', event=event,
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=0, payment_provider='banktransfer'
|
||||
total=0,
|
||||
)
|
||||
Team.objects.create(pk=1, organizer=o)
|
||||
return event, user, o
|
||||
@@ -103,6 +103,12 @@ event_urls = [
|
||||
"orders/ABC/comment",
|
||||
"orders/ABC/locale",
|
||||
"orders/ABC/checkvatid",
|
||||
"orders/ABC/payments/1/cancel",
|
||||
"orders/ABC/payments/1/confirm",
|
||||
"orders/ABC/refund",
|
||||
"orders/ABC/refunds/1/cancel",
|
||||
"orders/ABC/refunds/1/process",
|
||||
"orders/ABC/refunds/1/done",
|
||||
"orders/ABC/",
|
||||
"orders/",
|
||||
"checkinlists/",
|
||||
|
||||
@@ -28,7 +28,7 @@ class OrderSearchTest(SoupTest):
|
||||
code='FO1A', event=self.event1, email='dummy1@dummy.test',
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() + datetime.timedelta(days=10),
|
||||
total=14, payment_provider='banktransfer', locale='en'
|
||||
total=14, locale='en'
|
||||
)
|
||||
InvoiceAddress.objects.create(order=o1, company="Test Ltd.", name="Peter Miller")
|
||||
ticket1 = Item.objects.create(event=self.event1, name='Early-bird ticket',
|
||||
@@ -47,7 +47,7 @@ class OrderSearchTest(SoupTest):
|
||||
code='FO2', event=self.event2, email='dummy2@dummy.test',
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() + datetime.timedelta(days=10),
|
||||
total=14, payment_provider='banktransfer', locale='en'
|
||||
total=14, locale='en'
|
||||
)
|
||||
ticket2 = Item.objects.create(event=self.event1, name='Early-bird ticket',
|
||||
category=None, default_price=23,
|
||||
|
||||
@@ -29,7 +29,7 @@ class EventShredderTest(SoupTest):
|
||||
code='FOO', event=self.event1, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now(),
|
||||
total=14, payment_provider='banktransfer', locale='en'
|
||||
total=14, locale='en'
|
||||
)
|
||||
|
||||
self.client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
@@ -37,7 +37,7 @@ class EventShredderTest(SoupTest):
|
||||
def test_shred_simple(self):
|
||||
doc = self.get_doc('/control/event/%s/%s/shredder/' % (self.orga1.slug, self.event1.slug))
|
||||
assert doc.select("input[value=order_emails]")
|
||||
assert doc.select("input[value=stripe_logs]")
|
||||
assert doc.select("input[value=invoices]")
|
||||
doc = self.post_doc('/control/event/%s/%s/shredder/export' % (self.orga1.slug, self.event1.slug), {
|
||||
'shredder': 'order_emails'
|
||||
})
|
||||
@@ -67,7 +67,7 @@ class EventShredderTest(SoupTest):
|
||||
def test_shred_password_wrong(self):
|
||||
doc = self.get_doc('/control/event/%s/%s/shredder/' % (self.orga1.slug, self.event1.slug))
|
||||
assert doc.select("input[value=order_emails]")
|
||||
assert doc.select("input[value=stripe_logs]")
|
||||
assert doc.select("input[value=invoices]")
|
||||
doc = self.post_doc('/control/event/%s/%s/shredder/export' % (self.orga1.slug, self.event1.slug), {
|
||||
'shredder': 'order_emails'
|
||||
})
|
||||
@@ -97,7 +97,7 @@ class EventShredderTest(SoupTest):
|
||||
def test_shred_confirm_code_wrong(self):
|
||||
doc = self.get_doc('/control/event/%s/%s/shredder/' % (self.orga1.slug, self.event1.slug))
|
||||
assert doc.select("input[value=order_emails]")
|
||||
assert doc.select("input[value=stripe_logs]")
|
||||
assert doc.select("input[value=invoices]")
|
||||
doc = self.post_doc('/control/event/%s/%s/shredder/export' % (self.orga1.slug, self.event1.slug), {
|
||||
'shredder': 'order_emails'
|
||||
})
|
||||
@@ -137,7 +137,7 @@ class EventShredderTest(SoupTest):
|
||||
def test_shred_something_happened(self):
|
||||
doc = self.get_doc('/control/event/%s/%s/shredder/' % (self.orga1.slug, self.event1.slug))
|
||||
assert doc.select("input[value=order_emails]")
|
||||
assert doc.select("input[value=stripe_logs]")
|
||||
assert doc.select("input[value=invoices]")
|
||||
doc = self.post_doc('/control/event/%s/%s/shredder/export' % (self.orga1.slug, self.event1.slug), {
|
||||
'shredder': 'order_emails'
|
||||
})
|
||||
|
||||
@@ -83,7 +83,7 @@ class TaxRateFormTest(SoupTest):
|
||||
code='FOO', event=self.event1, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() + datetime.timedelta(days=10),
|
||||
total=14, payment_provider='banktransfer', locale='en',
|
||||
total=14, locale='en',
|
||||
)
|
||||
o.fees.create(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.25'), tax_rate=Decimal('19.00'),
|
||||
tax_value=Decimal('0.05'), tax_rule=tr)
|
||||
@@ -101,7 +101,7 @@ class TaxRateFormTest(SoupTest):
|
||||
code='FOO', event=self.event1, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() + datetime.timedelta(days=10),
|
||||
total=12, payment_provider='banktransfer', locale='en'
|
||||
total=12, locale='en'
|
||||
)
|
||||
o.positions.create(
|
||||
item=i, price=12, tax_rule=tr, tax_rate=19, tax_value=12 - 12 / 1.19
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from django.utils.timezone import now
|
||||
@@ -34,8 +35,22 @@ def order(item):
|
||||
o = Order.objects.create(event=item.event, status=Order.STATUS_PENDING,
|
||||
expires=now() + datetime.timedelta(hours=1),
|
||||
total=13, code='DUMMY', email='dummy@dummy.test',
|
||||
datetime=now(), payment_provider='banktransfer')
|
||||
datetime=now())
|
||||
OrderPosition.objects.create(order=o, item=item, price=13)
|
||||
p1 = o.payments.create(
|
||||
provider='stripe',
|
||||
state='refunded',
|
||||
amount=Decimal('23.00'),
|
||||
payment_date=o.datetime,
|
||||
)
|
||||
o.refunds.create(
|
||||
provider='stripe',
|
||||
state='done',
|
||||
source='admin',
|
||||
amount=Decimal('23.00'),
|
||||
execution_date=o.datetime,
|
||||
payment=p1,
|
||||
)
|
||||
return o
|
||||
|
||||
|
||||
@@ -131,6 +146,12 @@ def logged_in_client(client, event):
|
||||
('/control/event/{orga}/{event}/orders/{order_code}/comment', 405),
|
||||
('/control/event/{orga}/{event}/orders/{order_code}/change', 200),
|
||||
('/control/event/{orga}/{event}/orders/{order_code}/locale', 200),
|
||||
('/control/event/{orga}/{event}/orders/{order_code}/payments/{payment}/cancel', 200),
|
||||
('/control/event/{orga}/{event}/orders/{order_code}/payments/{payment}/confirm', 200),
|
||||
('/control/event/{orga}/{event}/orders/{order_code}/refund', 200),
|
||||
('/control/event/{orga}/{event}/orders/{order_code}/refunds/{refund}/cancel', 200),
|
||||
('/control/event/{orga}/{event}/orders/{order_code}/refunds/{refund}/process', 200),
|
||||
('/control/event/{orga}/{event}/orders/{order_code}/refunds/{refund}/done', 200),
|
||||
('/control/event/{orga}/{event}/orders/{order_code}/', 200),
|
||||
('/control/event/{orga}/{event}/orders/overview/', 200),
|
||||
('/control/event/{orga}/{event}/orders/export/', 200),
|
||||
@@ -141,6 +162,8 @@ def logged_in_client(client, event):
|
||||
])
|
||||
@pytest.mark.django_db
|
||||
def test_one_view(logged_in_client, url, expected, event, item, item_category, order, question, quota, voucher):
|
||||
payment = order.payments.first()
|
||||
refund = order.refunds.first()
|
||||
url = url.format(
|
||||
event=event.slug, orga=event.organizer.slug,
|
||||
category=item_category.pk,
|
||||
@@ -149,6 +172,8 @@ def test_one_view(logged_in_client, url, expected, event, item, item_category, o
|
||||
question=question.pk,
|
||||
quota=quota.pk,
|
||||
voucher=voucher.pk,
|
||||
payment=payment.pk,
|
||||
refund=refund.pk
|
||||
)
|
||||
response = logged_in_client.get(url)
|
||||
assert response.status_code == expected
|
||||
|
||||
@@ -23,7 +23,7 @@ def env():
|
||||
code='FOOBAR', event=event, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=Decimal('13.37'), payment_provider='banktransfer'
|
||||
total=Decimal('13.37'),
|
||||
)
|
||||
shirt = Item.objects.create(event=event, name='T-Shirt', default_price=12)
|
||||
shirt_red = ItemVariation.objects.create(item=shirt, default_price=14, value="Red")
|
||||
|
||||
@@ -25,13 +25,13 @@ def env():
|
||||
code='1Z3AS', event=event,
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=23, payment_provider='banktransfer'
|
||||
total=23,
|
||||
)
|
||||
o2 = Order.objects.create(
|
||||
code='6789Z', event=event,
|
||||
status=Order.STATUS_CANCELED,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=23, payment_provider='banktransfer'
|
||||
total=23,
|
||||
)
|
||||
quota = Quota.objects.create(name="Test", size=2, event=event)
|
||||
item1 = Item.objects.create(event=event, name="Ticket", default_price=23)
|
||||
@@ -56,23 +56,6 @@ def test_discard(env, client):
|
||||
assert trans.payer == ''
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_accept_wrong_amount(env, client):
|
||||
job = BankImportJob.objects.create(event=env[0])
|
||||
trans = BankTransaction.objects.create(event=env[0], import_job=job, payer='Foo',
|
||||
state=BankTransaction.STATE_INVALID,
|
||||
amount=12, date='unknown', order=env[2])
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
r = json.loads(client.post('/control/event/{}/{}/banktransfer/action/'.format(env[0].organizer.slug, env[0].slug), {
|
||||
'action_{}'.format(trans.pk): 'accept',
|
||||
}).content.decode('utf-8'))
|
||||
assert r['status'] == 'ok'
|
||||
trans.refresh_from_db()
|
||||
assert trans.state == BankTransaction.STATE_VALID
|
||||
env[2].refresh_from_db()
|
||||
assert env[2].status == Order.STATUS_PAID
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_assign_order(env, client):
|
||||
job = BankImportJob.objects.create(event=env[0])
|
||||
|
||||
@@ -28,13 +28,13 @@ def env():
|
||||
code='1Z3AS', event=event,
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=23, payment_provider='banktransfer'
|
||||
total=23
|
||||
)
|
||||
o2 = Order.objects.create(
|
||||
code='6789Z', event=event,
|
||||
status=Order.STATUS_CANCELED,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=23, payment_provider='banktransfer'
|
||||
total=23
|
||||
)
|
||||
quota = Quota.objects.create(name="Test", size=2, event=event)
|
||||
item1 = Item.objects.create(event=event, name="Ticket", default_price=23)
|
||||
|
||||
@@ -7,7 +7,8 @@ from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.utils.timezone import now
|
||||
|
||||
from pretix.base.models import (
|
||||
Event, Item, Order, OrderPosition, Organizer, Quota, Team, User,
|
||||
Event, Item, Order, OrderPayment, OrderPosition, Organizer, Quota, Team,
|
||||
User,
|
||||
)
|
||||
from pretix.plugins.banktransfer.models import BankImportJob
|
||||
from pretix.plugins.banktransfer.tasks import process_banktransfers
|
||||
@@ -28,19 +29,19 @@ def env():
|
||||
code='1Z3AS', event=event,
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=23, payment_provider='banktransfer'
|
||||
total=23
|
||||
)
|
||||
o2 = Order.objects.create(
|
||||
code='6789Z', event=event,
|
||||
status=Order.STATUS_CANCELED,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=23, payment_provider='banktransfer'
|
||||
total=23
|
||||
)
|
||||
Order.objects.create(
|
||||
code='GS89Z', event=event,
|
||||
status=Order.STATUS_CANCELED,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
total=23, payment_provider='banktransfer'
|
||||
total=23
|
||||
)
|
||||
quota = Quota.objects.create(name="Test", size=2, event=event)
|
||||
item1 = Item.objects.create(event=event, name="Ticket", default_price=23)
|
||||
@@ -110,7 +111,43 @@ def test_mark_paid(env, job):
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_check_amount(env, job):
|
||||
def test_underpaid(env, job):
|
||||
process_banktransfers(job, [{
|
||||
'payer': 'Karla Kundin',
|
||||
'reference': 'Bestellung DUMMY1Z3AS',
|
||||
'date': '2016-01-26',
|
||||
'amount': '22.50'
|
||||
}])
|
||||
env[2].refresh_from_db()
|
||||
assert env[2].status == Order.STATUS_PENDING
|
||||
p = env[2].payments.last()
|
||||
assert p.amount == Decimal('22.50')
|
||||
assert p.state == OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
assert env[2].pending_sum == Decimal('0.50')
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_in_parts(env, job):
|
||||
process_banktransfers(job, [{
|
||||
'payer': 'Karla Kundin',
|
||||
'reference': 'Bestellung DUMMY1Z3AS',
|
||||
'date': '2016-01-26',
|
||||
'amount': '10.00'
|
||||
}])
|
||||
process_banktransfers(job, [{
|
||||
'payer': 'Karla Kundin',
|
||||
'reference': 'Bestellung DUMMY1Z3AS',
|
||||
'date': '2016-01-26',
|
||||
'amount': '13.00'
|
||||
}])
|
||||
env[2].refresh_from_db()
|
||||
assert env[2].status == Order.STATUS_PAID
|
||||
assert env[2].payments.count() == 2
|
||||
assert env[2].pending_sum == Decimal('0.00')
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_overpaid(env, job):
|
||||
process_banktransfers(job, [{
|
||||
'payer': 'Karla Kundin',
|
||||
'reference': 'Bestellung DUMMY1Z3AS',
|
||||
@@ -118,7 +155,11 @@ def test_check_amount(env, job):
|
||||
'amount': '23.50'
|
||||
}])
|
||||
env[2].refresh_from_db()
|
||||
assert env[2].status == Order.STATUS_PENDING
|
||||
assert env[2].status == Order.STATUS_PAID
|
||||
p = env[2].payments.last()
|
||||
assert p.amount == Decimal('23.50')
|
||||
assert p.state == OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
assert env[2].pending_sum == Decimal('-0.50')
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user