Fix #571 -- Partial payments and refunds

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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