diff --git a/src/squarepay/admin.py b/src/squarepay/admin.py index 27157f1002ca8a26aa21f38ea840043c45d79ff7..5c5b26432f877323f8f22783d0d498de6e8da3db 100644 --- a/src/squarepay/admin.py +++ b/src/squarepay/admin.py @@ -1,7 +1,7 @@ from django.utils.html import format_html from gms import admin -from .models import CardPayment, MembershipPayment +from .models import CardPayment, MembershipPayment, TopUpPayment class CardPaymentAdmin(admin.ModelAdmin): list_display = ['amount', 'date_created', 'is_paid'] @@ -11,5 +11,10 @@ class CardPaymentAdmin(admin.ModelAdmin): class MembershipPaymentAdmin(CardPaymentAdmin): list_display = ['amount', 'date_created', 'is_paid', 'membership'] +class TopUpPaymentAdmin(CardPaymentAdmin): + list_display = ['username', 'amount', 'is_paid', 'date_paid', 'dispense_synced'] + readonly_fields = ['potential_error', 'idempotency_key'] + admin.site.register(CardPayment, CardPaymentAdmin) +admin.site.register(TopUpPayment, TopUpPaymentAdmin) admin.site.register(MembershipPayment, MembershipPaymentAdmin) diff --git a/src/squarepay/dispense.py b/src/squarepay/dispense.py index e32393578c39716e0fbff21c9ba9254b37a9caaa..84e7f623db8e342b557c6b86c7ad581fba786083 100644 --- a/src/squarepay/dispense.py +++ b/src/squarepay/dispense.py @@ -31,6 +31,45 @@ def run_dispense(*args): return None return res +def dispense_add_balance(user, amount, id): + """ + Adds `amount` (cents) to a `user`s dispense account and returns a tuple (output, error). + If an `id` is specified, it will be appended to the dispense reason, which is useful to trace back from the dispense log. + On success, error is None. On failure, output is None and error is a String. + """ + if DISPENSE_BIN is None: + return None, RuntimeError("DISPENSE_BIN is not defined") + + reason = "via portal" if id is None else f"via portal (id: {id})" + + cmd = (DISPENSE_BIN, "acct", user, "+"+str(amount), reason) + log.info("dispense_add_balance: " + str(cmd)) + try: + res = subprocess.check_output(cmd, timeout=4, universal_newlines=True) + return res, None + except CalledProcessError as e: + log.warning("dispense returned error code %d, output: '%s'" % (e.returncode, e.output)) + err = e + except TimeoutExpired as e: + log.error(e) + err = e + + # if dispense returned an error, coerce it into a reasonable string for storage. + parts = [] + if hasattr(err, 'output') and err.output: + parts.append(f"output: {err.output}") + if hasattr(err, 'stderr') and err.stderr: + parts.append(f"stderr: {err.stderr}") + if hasattr(err, 'message'): + parts.append(f"message: {err.message}") + + err_str = str(err) + if err_str and err_str not in parts: + parts.append(f"str: {err_str}") + + return None, " | ".join(parts) if parts else err_str + + def get_item_price(itemid): """ gets the price of the given dispense item in cents """ if (itemid is None or itemid == ""): diff --git a/src/squarepay/models.py b/src/squarepay/models.py index ab2248e4fe35a3f9072b1e61930db6340178d8bf..f7d7ad01c794f5a431c9a42e2ac1cf85fb3018e7 100644 --- a/src/squarepay/models.py +++ b/src/squarepay/models.py @@ -30,3 +30,10 @@ class MembershipPayment(CardPayment): self.membership.date_paid = timezone.now() self.membership.payment_method = 'online' super().set_paid() + +class TopUpPayment(CardPayment): + """ + Link the payment to a username and dispense output. + """ + username = models.CharField('Username', max_length=64) + potential_error = models.CharField('potential_error', max_length=2048) \ No newline at end of file diff --git a/src/squarepay/urls.py b/src/squarepay/urls.py index ac263a8c8dc1e77052e54570983f66dba65649df..5c86e662bda3ed8d680c367864351f1ebd6bea36 100644 --- a/src/squarepay/urls.py +++ b/src/squarepay/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from .views import PaymentFormView, MembershipPaymentView +from .views import PaymentFormView, MembershipPaymentView, CustomPaymentView, TopUpFormView # note that other apps (like memberdb) may have dependencies via the reverse URL # using something like reverse('squarepay:pay_membership', ...) @@ -8,4 +8,6 @@ from .views import PaymentFormView, MembershipPaymentView app_name = 'squarepay' urlpatterns = [ path('pay/<int:pk>/', MembershipPaymentView.as_view(), name='pay_membership'), + path('custom-pay/<int:amount>/<slug:description>/<uuid:idempotency>/', CustomPaymentView.as_view(), name='custom_payment'), + path('topup/', TopUpFormView.as_view(), name='topup_form'), ] diff --git a/src/squarepay/views.py b/src/squarepay/views.py index fd8ae945a7dc4a21a2cde7e2818a6b75e16129a3..d257a95a5a034b1dfb1cf0ebfee7868078c04e14 100644 --- a/src/squarepay/views.py +++ b/src/squarepay/views.py @@ -1,6 +1,7 @@ import json +import math import uuid -from django.views.generic.base import RedirectView, View +from django.views.generic.base import RedirectView, View, TemplateView from django.views.generic.detail import DetailView from django.http import HttpResponse, HttpResponseRedirect, Http404 from django.core.exceptions import ObjectDoesNotExist @@ -12,10 +13,13 @@ from django.utils import timezone from memberdb.views import MemberAccessMixin from memberdb.models import Membership -from .models import MembershipPayment, CardPayment +from .models import MembershipPayment, CardPayment, TopUpPayment from . import payments from .payments import try_capture_payment, log from .dispense import get_item_price +from .dispense import run_dispense, dispense_add_balance + +SQUARE_FEE = 0.022 # 2.2% as of 2025 class PaymentFormMixin: template_name = 'payment_form.html' @@ -148,3 +152,83 @@ def create_membership_payment(membership, commit=True): if (commit): payment.save() return payment + +class CustomPaymentView(MemberAccessMixin, PaymentFormMixin, DetailView): + model = CardPayment + + def dispatch(self, request, *args, **kwargs): + if self.get_object().is_paid: + return HttpResponseRedirect(reverse('memberdb:home')) + + return super().dispatch(request, *args, **kwargs) + + def get_object(self): + if self.request.member is None: + raise Http404("no member record associated with current session") + + amount = self.kwargs.get('amount') + description = self.kwargs.get('description') + idempotency = self.kwargs.get('idempotency') + + if not amount or not description or not idempotency: + raise HttpResponseRedirect(reverse('squarepay:topup_form')) + + payment, _ = TopUpPayment.objects.get_or_create( + amount=amount, + description=description, + idempotency_key=idempotency, + username=self.request.member.username, + defaults={'potential_error': ''} + ) + + return payment + + def payment_success(self, payment): + super().payment_success(payment) + _, err = dispense_add_balance(payment.username, payment.amount, payment.idempotency_key) + + if err is not None: + payment.save(update_fields=['potential_error']) + messages.error(self.request, f"We could not sync your payment with dispense. No money has been added to your account. Contact committee with your username for a refund. (ID: {payment.idempotency_key}))") + else: + payment.dispense_synced = True + payment.save(update_fields=['dispense_synced']) + + def payment_error(self, payment, error): + super().payment_error(self, payment, error) + +class TopUpFormView(MemberAccessMixin, TemplateView): + template_name = 'topup_form.html' + + def get_balance(self): + output = run_dispense("acct", self.request.member.username) + return output.split()[3] + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['balance'] = self.get_balance() + context['square_fee'] = round(SQUARE_FEE * 100, 2) + return context + + def post(self, request, *args, **kwargs): + amount = request.POST.get('amount') + + try: + amount_cents = int(float(amount) * 100) + except: + messages.error(request, "Please enter a valid amount") + return self.get(request, *args, **kwargs) + + if amount_cents < 100: # Minimum $1 + messages.error(request, "Minimum top-up amount is $1.00") + return self.get(request, *args, **kwargs) + + amount_cents = math.ceil(amount_cents / (1 - SQUARE_FEE)) + + idempotency = uuid.uuid1() + + return HttpResponseRedirect(reverse('squarepay:custom_payment', kwargs={ + 'amount': amount_cents, + 'description': 'portal-topup', + 'idempotency': idempotency + })) \ No newline at end of file diff --git a/src/templates/base.html b/src/templates/base.html index 1710d6a2daf95b22e08de6dc1024c4853b882a7f..3b24ec1a304432502dce92fff9911e280b05753c 100644 --- a/src/templates/base.html +++ b/src/templates/base.html @@ -39,6 +39,7 @@ <a class="navtab {% if url_name == 'login' %}active{% endif %}" href="{% url "memberdb:login" %}">Login</a> {% else %} <a class="navtab {% if url_name == 'renew' %}active{% endif %}" href="{% url "memberdb:renew" %}">Renew membership</a> + <a class="navtab {% if url_name == 'topup_form' %}active{% endif %}" href="{% url "squarepay:topup_form" %}">Top up account</a> {% if request.user.is_staff %} <a class="navtab {% block adminactive %}{% endblock %}" href="{% url "admin:index" %}">Admin site</a> diff --git a/src/templates/topup_form.html b/src/templates/topup_form.html new file mode 100644 index 0000000000000000000000000000000000000000..c2a856c2a7fb30a3f31bbe82b832793a28392993 --- /dev/null +++ b/src/templates/topup_form.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} + +{% block title %}Top up account - UCC MemberDB{% endblock %} + +{% block content %} +<div class="topup-form"> + <h1>Top up your account</h1> + <p> + You currently have <strong>${{ balance }}</strong>. + </p> + <form method="post"> + {% csrf_token %} + <div class="form-row"> + <label for="amount">Amount (AUD):</label> + <div style="display: flex; align-items: center;"> + <span style="font-weight: bold; margin-right: 4px;">$</span> + <input type="number" name="amount" id="amount" min="1" step="1" required style="flex: 1;"> + </div> + <div style="margin-top: 8px;"> + <button type="button" onclick="document.getElementById('amount').value=10;">$10</button> + <button type="button" onclick="document.getElementById('amount').value=20;">$20</button> + <button type="button" onclick="document.getElementById('amount').value=50;">$50</button> + </div> + </div> + <div class="form-row"> + <input type="submit" value="Continue to payment"> + </div> + <em>Square charges {{square_fee}}% on API payments, which will be added to your transaction.</em> + </form> +</div> +{% endblock %} + +{% block extrastyle %} +<style> +.topup-form { + max-width: 400px; + margin: 20px auto; + padding: 20px; + background: #fff; + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.form-row { + margin-bottom: 15px; +} + +.form-row label { + display: block; + margin-bottom: 5px; + font-weight: bold; +} + +.form-row input[type="number"] { + width: 100%; + padding: 8px; + border: 1px solid #ddd; + border-radius: 4px; +} + +.form-row input[type="submit"] { + background: #417690; + color: white; + padding: 10px 20px; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.form-row input[type="submit"]:hover { + background: #295570; +} +</style> +{% endblock %} \ No newline at end of file