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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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