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

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