Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Top Up Products #12

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion pretix_wallet/models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,56 @@
import datetime

from django.db import models
from pretix.base.models import Customer, GiftCard
from pretix.base.models import Customer, GiftCard, MembershipType, Membership
from pretix.base.models.giftcards import gen_giftcard_secret

VERY_FAR_FUTURE = datetime.date(2099, 12, 12)


class CustomerWallet(models.Model):
customer = models.OneToOneField(Customer, on_delete=models.CASCADE, related_name='wallet')
giftcard = models.OneToOneField(GiftCard, on_delete=models.CASCADE, related_name='wallet')

@staticmethod
def create_if_non_existent(organizer, customer):
try:
_ = customer.wallet
except CustomerWallet.DoesNotExist:
giftcard = GiftCard.objects.create(
issuer=organizer,
currency="EUR",
conditions=f"Wallet for {customer.name_cached} ({customer.email})",
secret=f"{customer.email.split('@')[0]}-{gen_giftcard_secret(length=organizer.settings.giftcard_length)}")
CustomerWallet.objects.create(customer=customer, giftcard=giftcard)
create_membership_if_not_existant(organizer, customer)


def membership_type_name_for_organizer(organizer):
return f"{organizer.name} Wallet"


def get_or_create_wallet_membership_type(organizer):
membership_type = organizer.membership_types.filter(name=membership_type_name_for_organizer(organizer)).first()

if membership_type is not None:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.filter(...).exists() is the prettier sytnax

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I read the docs correctly, that doesn't give me the object (which I want to return, because I use it in lines 49 and 53). But in line 49, I can use exists. I've changed that.

return membership_type

return MembershipType.objects.create(
name=membership_type_name_for_organizer(organizer),
organizer=organizer,
allow_parallel_usage=True,
transferable=False,
max_usages=None,
)


def create_membership_if_not_existant(organizer, customer):
membership_type = get_or_create_wallet_membership_type(organizer)
if not membership_type.memberships.filter(customer=customer).exists():
Membership.objects.create(
testmode=False,
customer=customer,
membership_type=membership_type,
date_start=datetime.date.today(),
date_end=VERY_FAR_FUTURE
)
79 changes: 54 additions & 25 deletions pretix_wallet/payment.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import ast
from _decimal import Decimal
from collections import OrderedDict
from typing import Dict, Any, Union

from django.contrib import messages
from django.db import transaction
from django.forms import CharField
from django.forms import CharField, MultipleChoiceField, CheckboxSelectMultiple
from django.http import HttpRequest
from django.utils.crypto import get_random_string
from django.utils.translation import gettext_lazy as _
Expand All @@ -13,6 +14,7 @@
from pretix.base.models.customers import CustomerSSOProvider
from pretix.base.payment import PaymentException, GiftCardPayment
from pretix.base.services.cart import add_payment_to_cart
from pretix.control.forms.item import ItemVariationForm
from pretix.helpers import OF_SELF
from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.views.cart import cart_session
Expand All @@ -25,10 +27,10 @@ class WalletPaymentProvider(GiftCardPayment):
verbose_name = _("Wallet")
public_name = _("Wallet")

def payment_form_render(self, request: HttpRequest, total: Decimal, order: Order=None) -> str:
def payment_form_render(self, request: HttpRequest, total: Decimal, order: Order = None) -> str:
return "Wallet payment form"

def checkout_confirm_render(self, request, order: Order=None, info_data: dict=None) -> str:
def checkout_confirm_render(self, request, order: Order = None, info_data: dict = None) -> str:
return "Wallet confirm"

def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str]:
Expand All @@ -37,7 +39,8 @@ def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[
messages.error(request, _("You do not have a wallet."))
return False
if request.customer.wallet.giftcard.value < 0:
messages.error(request, _("Your wallet has a negative balance. Please top it up or use another payment method."))
messages.error(request,
_("Your wallet has a negative balance. Please top it up or use another payment method."))
return False
cart_session(request)
add_payment_to_cart(
Expand All @@ -47,7 +50,8 @@ def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[
info_data=self._get_payment_info_data(request.customer.wallet),
)
return True
return self._redirect_user(request, build_absolute_uri(request.event, "presale:event.checkout", kwargs={"step": "payment"}))
return self._redirect_user(request, build_absolute_uri(request.event, "presale:event.checkout",
kwargs={"step": "payment"}))

def _redirect_user(self, request: HttpRequest, next_url: str):
provider = CustomerSSOProvider.objects.last()
Expand Down Expand Up @@ -80,24 +84,15 @@ def execute_payment(self, request: HttpRequest, payment: OrderPayment, is_early_
if not gcpk:
raise PaymentException("Invalid state, should never occur.")
try:
with transaction.atomic():
try:
gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gcpk)
except GiftCard.DoesNotExist:
raise PaymentException(_("This gift card does not support this currency."))
if gc.currency != self.event.currency: # noqa - just a safeguard
raise PaymentException(_("This gift card does not support this currency."))
if not gc.accepted_by(self.event.organizer):
raise PaymentException(_("This gift card is not accepted by this event organizer."))

trans = gc.transactions.create(
value=-1 * payment.amount,
order=payment.order,
payment=payment,
acceptor=self.event.organizer,
)
payment.info_data['transaction_id'] = trans.pk
payment.confirm(send_mail=not is_early_special_case, generate_invoice=not is_early_special_case)
try:
gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gcpk)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.get() evaluates the queryset, which activates the lock. if you want to keep it locked, the evaluation needs to happen inside the same transaction as the other operations

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a reason to keep the lock? Shouldn't the lock around the write part be enough? Ore are you worried about the gift card changing owners?

except GiftCard.DoesNotExist:
raise PaymentException(_("This gift card does not support this currency."))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exception text is wrong (not your fault)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What should it say?


trans = _wallet_transaction(self.event, payment, gc)
payment.info_data['transaction_id'] = trans.pk
payment.confirm(send_mail=not is_early_special_case, generate_invoice=not is_early_special_case)

except PaymentException as e:
payment.fail(info={'error': str(e)})
raise e
Expand All @@ -108,7 +103,8 @@ def payment_prepare(self, request: HttpRequest, payment: OrderPayment) -> Union[
messages.error(request, _("You do not have a wallet."))
return False
if request.customer.wallet.giftcard.value < 0:
messages.error(request, _("Your wallet has a negative balance. Please top it up or use another payment method."))
messages.error(request,
_("Your wallet has a negative balance. Please top it up or use another payment method."))
return False
gc = request.customer.wallet.giftcard
if gc not in self.event.organizer.accepted_gift_cards:
Expand All @@ -121,6 +117,39 @@ def payment_prepare(self, request: HttpRequest, payment: OrderPayment) -> Union[

@property
def settings_form_fields(self):
product_choices = list(map(lambda product: (str(product.pk), product.name), self.event.items.all()))

return OrderedDict(list(super().settings_form_fields.items()) + [
('api_key', CharField(label=_("API key"), help_text=_("The API key that the terminal uses to authenticate against the POS api provided by this plugin."))),
('api_key', CharField(label=_("API key"), help_text=_(
"The API key that the terminal uses to authenticate against the POS api provided by this plugin."))),
('top_up_products', CharField(
label=_("Products used to charge wallet accounts"),
help_text=_(
"Comma separated list of English product names, Buying these products will charge a users wallet by"
"their price. Remeber, to require the Wallet memberships for these products to force users to login."),
))
# ('top_up_products', MultipleChoiceField(
# widget=CheckboxSelectMultiple,
# label=_("Products used to charge wallet accounts"),
# required=False,
# initial=[0],
# # initial=ast.literal_eval(self.event.settings.get('payment_wallet_top_up_products')),
# help_text=_(
# "Buying these products will charge a users wallet by their price. Remeber, to require the Wallet memberships for these products to force users to login."),
# choices=product_choices))
])


def _wallet_transaction(event, payment: OrderPayment, gift_card: GiftCard, sign=-1, amount=None):
with transaction.atomic():
if gift_card.currency != event.currency: # noqa - just a safeguard
raise PaymentException(_("This gift card does not support this currency."))
if not gift_card.accepted_by(event.organizer):
raise PaymentException(_("This gift card is not accepted by this event organizer."))

return gift_card.transactions.create(
value=sign * (amount if amount else payment.amount),
order=payment.order,
payment=payment,
acceptor=event.organizer,
)
74 changes: 72 additions & 2 deletions pretix_wallet/signals.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,79 @@
from django.core.exceptions import ImproperlyConfigured
from django.dispatch import receiver
from pretix.base.signals import register_payment_providers
from django.http import Http404
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _

from pretix_wallet.payment import WalletPaymentProvider
from pretix.base.payment import PaymentException
from pretix.base.signals import register_payment_providers, order_paid
from pretix.helpers.http import redirect_to_url
from pretix.presale.checkoutflow import BaseCheckoutFlowStep, CartMixin, TemplateFlowStep
from pretix.presale.signals import checkout_flow_steps
from pretix.presale.views.cart import cart_session
from pretix_wallet.models import CustomerWallet

from pretix_wallet.payment import WalletPaymentProvider, _wallet_transaction


@receiver(register_payment_providers, dispatch_uid="payment_wallet")
def register_payment_provider(sender, **kwargs):
return [WalletPaymentProvider]


@receiver(checkout_flow_steps, dispatch_uid="checkout_flow_steps_wallet")
def wallet_checkout_flow_steps(sender, **kwargs):
return MembershipGrantingCheckoutFlowStep


@receiver(order_paid, dispatch_uid="payment_wallet_order_paid")
def wallet_order_paid(sender, order, **kwargs):
top_up_positions = list(filter(lambda pos: position_is_top_up_product(sender, pos), order.positions.all()))
if top_up_positions:
CustomerWallet.create_if_non_existent(sender.organizer, order.customer)
try:
top_up_value = sum(map(lambda pos: pos.price, top_up_positions))
_wallet_transaction(sender, order.payments.last(), order.customer.wallet.giftcard, sign=1,
amount=top_up_value)
except PaymentException as e:
raise e


class MembershipGrantingCheckoutFlowStep(CartMixin, BaseCheckoutFlowStep):
icon = 'user-plus'
identifier = 'wallet-membership-granting'

def label(self):
return _('Creating Wallet')

@cached_property
def priority(self):
# One less than MembershipStep
return 46

def get(self, request):
if request.event.organizer and request.customer:
CustomerWallet.create_if_non_existent(request.event.organizer, request.customer)
return redirect_to_url(self.get_next_url(request))
else:
raise ImproperlyConfigured('User reached the wallet creation step without signing in.'
'Have you created customer accounts and required membership'
'for the top-up product?')

def is_completed(self, request, warn=False):
if self.request.customer.wallet:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC this raises if not existent

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this should never happen (see get). This I intended this to raise as this ever happening is a bug. Then, some of our central assumptions must be pretty wrong.

return True
return False

def is_applicable(self, request):
self.request = request
if not request.event.settings.get("payment_wallet_top_up_products"):
return False
return any(map(lambda pos: position_is_top_up_product(request.event, pos), self.positions))


def position_is_top_up_product(event, position):
if not event.settings.get("payment_wallet_top_up_products"):
return False
top_up_products = event.settings.get("payment_wallet_top_up_products").lower().split(',')
product_name = position.item.name.localize('en').lower()
return product_name in top_up_products
3 changes: 2 additions & 1 deletion pretix_wallet/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from pretix.api.urls import event_router

from pretix_wallet.views import TransactionListView, ProductViewSet, WalletViewSet, TransactionViewSet, PairingView, \
RemovePairingView
RemovePairingView, WalletRequiredRedirectView

app_name = 'pretix_wallet'

Expand All @@ -12,6 +12,7 @@

organizer_patterns = [
path('account/wallet/', TransactionListView.as_view(), name='transactions'),
path('account/wallet/login', WalletRequiredRedirectView.as_view(), name='wallet_login'),
path('account/wallet/pair/<str:token_id>/', PairingView.as_view(), name='pair'),
path('account/wallet/unpair/', RemovePairingView.as_view(), name='unpair'),
]
21 changes: 11 additions & 10 deletions pretix_wallet/views.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
from django.contrib import messages
from django.http import Http404
from django.shortcuts import redirect
from django.urls import reverse
from django.views import View
from django.views.generic import ListView, TemplateView
from django.utils.translation import gettext_lazy as _
from pretix.base.media import NfcUidMediaType
from pretix.base.models import GiftCardTransaction, Item, ReusableMedium, GiftCard
from pretix.base.models.giftcards import gen_giftcard_secret
from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.utils import _detect_event
from pretix.presale.views.customer import CustomerRequiredMixin
from rest_framework.mixins import RetrieveModelMixin, CreateModelMixin
Expand All @@ -27,15 +28,7 @@ class TerminalAuthMixin:

class WalletRequiredMixin:
def dispatch(self, request, *args, **kwargs):
try:
_ = request.customer.wallet
except CustomerWallet.DoesNotExist:
giftcard = GiftCard.objects.create(
issuer=request.organizer,
currency="EUR",
conditions=f"Wallet for {request.customer.name_cached} ({request.customer.email})",
secret=f"{request.customer.email.split('@')[0]}-{gen_giftcard_secret(length=request.organizer.settings.giftcard_length)}")
CustomerWallet.objects.create(customer=request.customer, giftcard=giftcard)
CustomerWallet.create_if_non_existent(request.organizer, request.customer)
return super().dispatch(request, *args, **kwargs)


Expand Down Expand Up @@ -115,3 +108,11 @@ def create(self, request, *args, **kwargs):
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
return Response(WalletSerializer(self.wallet).data, status=HTTP_201_CREATED)


class WalletRequiredRedirectView(CustomerRequiredMixin, WalletRequiredMixin, View):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need this?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll keep this until we've verified the checkout step actually works. If that's the case, we can remove it.

def get(self, *args, **kwargs):
if self.request.GET.get('next'):
return redirect(self.request.GET.get('next'))
else:
return redirect(build_absolute_uri(self.request.organizer, "plugins:pretix_wallet:wallet"))