Compare commits

...

3 Commits

Author SHA1 Message Date
Raphael Michel
02c81e0fa7 Stripe connect: Allow to set application fee 2019-10-13 13:30:58 +02:00
Raphael Michel
b9a911dd97 Fix #1440 -- API confusion about creating free orders 2019-10-11 09:00:46 +02:00
Raphael Michel
361488f3e6 Bump to 3.3.0.dev0 2019-10-10 18:17:27 +02:00
11 changed files with 174 additions and 12 deletions

View File

@@ -785,8 +785,9 @@ Creating orders
* ``locale``
* ``sales_channel``
* ``payment_provider`` (optional) The identifier of the payment provider set for this order. This needs to be an
existing payment provider. You should use ``"free"`` for free orders, and we strongly advise to use ``"manual"``
for all orders you create as paid.
existing payment provider. You should use ``"free"`` for free orders, and we strongly advise to use ``"manual"``
for all orders you create as paid. This field is optional when the order status is ``"n"`` or the order total is
zero, otherwise it is required.
* ``payment_info`` (optional) You can pass a nested JSON object that will be set as the internal ``info``
value of the payment object that will be created. How this value is handled is up to the payment provider and you
should only use this if you know the specific payment provider in detail. Please keep in mind that the payment

View File

@@ -1 +1 @@
__version__ = "3.2.0"
__version__ = "3.3.0.dev0"

View File

@@ -858,6 +858,9 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
order.total = sum([p.price for p in order.positions.all()]) + sum([f.value for f in order.fees.all()])
order.save(update_fields=['total'])
if order.total == Decimal('0.00') and validated_data.get('status') == Order.STATUS_PAID and not payment_provider:
payment_provider = 'free'
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID:
order.status = Order.STATUS_PAID
order.save()

View File

@@ -2,6 +2,8 @@ from django import template
from django.template import Node
from django.utils.translation import ugettext as _
from pretix.base.models import Event
register = template.Library()
@@ -39,15 +41,19 @@ class PropagatedNode(Node):
</div>
""".format(
body=body,
text_inh=_("Organizer-level settings"),
text_inh=_("Organizer-level settings") if isinstance(event, Event) else _('Site-level settings'),
fnames=','.join(self.field_names),
text_expl=_(
'These settings are currently set on organizer level. This way, you can easily change them for '
'all of your events at the same time. You can either go to the organizer settings to change them '
'or decouple them from the organizer account to change them for this event individually.'
) if isinstance(event, Event) else _(
'These settings are currently set on global level. This way, you can easily change them for '
'all organizers at the same time. You can either go to the global settings to change them '
'or decouple them from the global settings to change them for this event individually.'
),
text_unlink=_('Change only for this event'),
text_orga=_('Change for all events'),
text_unlink=_('Change only for this event') if isinstance(event, Event) else _('Change only for this organizer'),
text_orga=_('Change for all events') if isinstance(event, Event) else _('Change for all organizers'),
url=url
)

View File

@@ -1,6 +1,8 @@
from django import forms
from django.utils.translation import ugettext_lazy as _
from pretix.base.forms import SettingsForm
class StripeKeyValidator:
def __init__(self, prefix):
@@ -21,3 +23,18 @@ class StripeKeyValidator:
'prefix': self._prefixes[0],
},
)
class OrganizerStripeSettingsForm(SettingsForm):
payment_stripe_connect_app_fee_percent = forms.DecimalField(
label=_('Stripe Connect: App fee (percent)'),
required=False,
)
payment_stripe_connect_app_fee_max = forms.DecimalField(
label=_('Stripe Connect: App fee (max)'),
required=False,
)
payment_stripe_connect_app_fee_min = forms.DecimalField(
label=_('Stripe Connect: App fee (min)'),
required=False,
)

View File

@@ -3,6 +3,7 @@ import json
import logging
import urllib.parse
from collections import OrderedDict
from decimal import Decimal
import stripe
from django import forms
@@ -256,6 +257,20 @@ class StripeMethod(BasePaymentProvider):
def _get_amount(self, payment):
return self._decimal_to_int(payment.amount)
def _connect_kwargs(self, payment):
d = {}
if self.settings.connect_client_id and self.settings.connect_user_id:
fee = Decimal('0.00')
if self.settings.get('connect_app_fee_percent', as_type=Decimal):
fee = round_decimal(self.settings.get('connect_app_fee_percent', as_type=Decimal) * payment.amount / Decimal('100.00'), self.event.currency)
if self.settings.connect_app_fee_max:
fee = min(fee, self.settings.get('connect_app_fee_max', as_type=Decimal))
if self.settings.get('connect_app_fee_min', as_type=Decimal):
fee = max(fee, self.settings.get('connect_app_fee_min', as_type=Decimal))
if fee:
d['application_fee_amount'] = self._decimal_to_int(fee)
return d
@property
def api_kwargs(self):
if self.settings.connect_client_id and self.settings.connect_user_id:
@@ -301,6 +316,7 @@ class StripeMethod(BasePaymentProvider):
code=payment.order.code
)[:22]
params.update(self.api_kwargs)
params.update(self._connect_kwargs(payment))
charge = stripe.Charge.create(
amount=self._get_amount(payment),
currency=self.event.currency.lower(),
@@ -612,6 +628,9 @@ class StripeCC(StripeMethod):
try:
if self.payment_is_valid_session(request):
params = {}
params.update(self._connect_kwargs(payment))
params.update(self.api_kwargs)
intent = stripe.PaymentIntent.create(
amount=self._get_amount(payment),
currency=self.event.currency.lower(),
@@ -638,7 +657,7 @@ class StripeCC(StripeMethod):
'payment': payment.pk,
'hash': hashlib.sha1(payment.order.secret.lower().encode()).hexdigest(),
}),
**self.api_kwargs
**params
)
else:
payment_info = json.loads(payment.info)

View File

@@ -4,7 +4,7 @@ from collections import OrderedDict
from django import forms
from django.dispatch import receiver
from django.template.loader import get_template
from django.urls import resolve
from django.urls import resolve, reverse
from django.utils.translation import ugettext_lazy as _
from pretix.base.settings import settings_hierarkey
@@ -12,6 +12,7 @@ from pretix.base.signals import (
logentry_display, register_global_settings, register_payment_providers,
requiredaction_display,
)
from pretix.control.signals import nav_organizer
from pretix.plugins.stripe.forms import StripeKeyValidator
from pretix.presale.signals import html_head
@@ -121,6 +122,18 @@ def register_global_settings(sender, **kwargs):
StripeKeyValidator('pk_test_'),
),
)),
('payment_stripe_connect_app_fee_percent', forms.DecimalField(
label=_('Stripe Connect: App fee (percent)'),
required=False,
)),
('payment_stripe_connect_app_fee_max', forms.DecimalField(
label=_('Stripe Connect: App fee (max)'),
required=False,
)),
('payment_stripe_connect_app_fee_min', forms.DecimalField(
label=_('Stripe Connect: App fee (min)'),
required=False,
)),
])
@@ -141,3 +154,20 @@ def pretixcontrol_action_display(sender, action, request, **kwargs):
ctx = {'data': data, 'event': sender, 'action': action}
return template.render(ctx, request)
@receiver(nav_organizer, dispatch_uid="stripe_nav_organizer")
def nav_o(sender, request, organizer, **kwargs):
if request.user.has_active_staff_session(request.session.session_key):
url = resolve(request.path_info)
return [{
'label': _('Stripe Connect'),
'url': reverse('plugins:stripe:settings.connect', kwargs={
'organizer': request.organizer.slug
}),
'parent': reverse('control:organizer.edit', kwargs={
'organizer': request.organizer.slug
}),
'active': 'settings.connect' in url.url_name,
}]
return []

View File

@@ -0,0 +1,24 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load hierarkey_form %}
{% load formset_tags %}
{% block title %}{% trans "Stripe Connect" %}{% endblock %}
{% block content %}
<h1>
{% trans "Stripe Connect" %}
</h1>
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
{% csrf_token %}
{% url "control:global.settings" as g_url %}
{% propagated request.organizer g_url "payment_stripe_connect_app_fee_percent" "payment_stripe_connect_app_fee_min" "payment_stripe_connect_app_fee_max" %}
{% bootstrap_form form layout="control" %}
{% endpropagated %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -3,8 +3,9 @@ from django.conf.urls import include, url
from pretix.multidomain import event_url
from .views import (
ReturnView, ScaReturnView, ScaView, applepay_association, oauth_disconnect,
oauth_return, redirect_view, webhook,
OrganizerSettingsFormView, ReturnView, ScaReturnView, ScaView,
applepay_association, oauth_disconnect, oauth_return, redirect_view,
webhook,
)
event_patterns = [
@@ -24,6 +25,8 @@ organizer_patterns = [
urlpatterns = [
url(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/stripe/disconnect/',
oauth_disconnect, name='oauth.disconnect'),
url(r'^control/organizer/(?P<organizer>[^/]+)/stripeconnect/',
OrganizerSettingsFormView.as_view(), name='settings.connect'),
url(r'^_stripe/webhook/$', webhook, name='webhook'),
url(r'^_stripe/oauth_return/$', oauth_return, name='oauth.return'),
url(r'^.well-known/apple-developer-merchantid-domain-association$', applepay_association, name='applepay.association'),

View File

@@ -17,14 +17,20 @@ from django.views import View
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 django.views.generic import FormView
from django_scopes import scopes_disabled
from pretix.base.models import Event, Order, OrderPayment, Quota
from pretix.base.models import Event, Order, OrderPayment, Organizer, Quota
from pretix.base.payment import PaymentException
from pretix.base.services.locking import LockTimeoutException
from pretix.base.settings import GlobalSettingsObject
from pretix.control.permissions import event_permission_required
from pretix.control.permissions import (
AdministratorPermissionRequiredMixin, event_permission_required,
)
from pretix.control.views.event import DecoupleMixin
from pretix.control.views.organizer import OrganizerDetailViewMixin
from pretix.multidomain.urlreverse import eventreverse
from pretix.plugins.stripe.forms import OrganizerStripeSettingsForm
from pretix.plugins.stripe.models import ReferencedStripeObject
from pretix.plugins.stripe.payment import StripeCC, StripeSettingsHolder
from pretix.plugins.stripe.tasks import (
@@ -580,3 +586,37 @@ class ScaReturnView(StripeOrderView, View):
self.order.refresh_from_db()
return render(request, 'pretixplugins/stripe/sca_return.html', {'order': self.order})
class OrganizerSettingsFormView(DecoupleMixin, OrganizerDetailViewMixin, AdministratorPermissionRequiredMixin, FormView):
model = Organizer
permission = 'can_change_organizer_settings'
form_class = OrganizerStripeSettingsForm
template_name = 'pretixplugins/stripe/organizer_stripe.html'
def get_success_url(self):
return reverse('plugins:stripe:settings.connect', kwargs={
'organizer': self.request.organizer.slug,
})
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['obj'] = self.request.organizer
return kwargs
@transaction.atomic
def post(self, request, *args, **kwargs):
form = self.get_form()
if form.is_valid():
form.save()
if form.has_changed():
self.request.organizer.log_action(
'pretix.organizer.settings', user=self.request.user, data={
k: form.cleaned_data.get(k) for k in form.changed_data
}
)
messages.success(self.request, _('Your changes have been saved.'))
return redirect(self.get_success_url())
else:
messages.error(self.request, _('We could not save your changes. See below for details.'))
return self.get(request)

View File

@@ -1656,6 +1656,25 @@ def test_order_email_optional(token_client, organizer, event, item, quota, quest
assert not o.email
@pytest.mark.django_db
def test_order_create_payment_provider_optional_free(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
res['positions'][0]['price'] = '0.00'
res['positions'][0]['status'] = 'p'
del res['payment_provider']
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
with scopes_disabled():
o = Order.objects.get(code=resp.data['code'])
assert not o.payments.exists()
@pytest.mark.django_db
def test_order_create_payment_info_optional(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)