-
Notifications
You must be signed in to change notification settings - Fork 0
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
base: main
Are you sure you want to change the base?
Top Up Products #12
Changes from all commits
fac3762
961f792
9e19953
15f08a4
52e9e2d
2c2a8ef
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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: | ||
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 | ||
) |
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 _ | ||
|
@@ -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 | ||
|
@@ -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]: | ||
|
@@ -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( | ||
|
@@ -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() | ||
|
@@ -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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.")) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. exception text is wrong (not your fault) There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
@@ -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: | ||
|
@@ -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, | ||
) |
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: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IIRC this raises if not existent There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, this should never happen (see |
||
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 |
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 | ||
|
@@ -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) | ||
|
||
|
||
|
@@ -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): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why do we need this? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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")) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.