From bfebcc22a618a05cb9220465dd9c9af91a7489eb Mon Sep 17 00:00:00 2001 From: bir-d <20701908+bir-d@users.noreply.github.com> Date: Thu, 12 Jun 2025 22:01:09 +0800 Subject: [PATCH 1/2] feat: add topup form to uccportal --- src/squarepay/admin.py | 7 ++- src/squarepay/dispense.py | 21 ++++++++ src/squarepay/models.py | 7 +++ src/squarepay/urls.py | 4 +- src/squarepay/views.py | 97 ++++++++++++++++++++++++++++++++++- src/templates/base.html | 1 + src/templates/topup_form.html | 74 ++++++++++++++++++++++++++ 7 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 src/templates/topup_form.html diff --git a/src/squarepay/admin.py b/src/squarepay/admin.py index 27157f1..5c5b264 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 e323935..9a50d1f 100644 --- a/src/squarepay/dispense.py +++ b/src/squarepay/dispense.py @@ -31,6 +31,27 @@ def run_dispense(*args): return None return res +def run_dispense_return_error(*args): + """ + A variant of run_dispense that returns a tuple (output, error). + On success, error is None. On failure, output is None and error is the exception instance. + """ + if DISPENSE_BIN is None: + return None, RuntimeError("DISPENSE_BIN is not defined") + + cmd = (DISPENSE_BIN, ) + args + log.info("run_dispense_return_error: " + 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)) + return None, e + except TimeoutExpired as e: + log.error(e) + return None, e + + 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 ab2248e..f7d7ad0 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 ac263a8..5c86e66 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 fd8ae94..a7fcd6b 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,11 @@ 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, run_dispense_return_error class PaymentFormMixin: template_name = 'payment_form.html' @@ -148,3 +150,94 @@ 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 = run_dispense_return_error("acct", payment.username, "+"+str(payment.amount), "via portal") + + # if dispense returned an error, coerce it into a reasonable string and store it. + if err is not None: + 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}") + payment.potential_error = " | ".join(parts) if parts else err_str + 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() + return context + + def post(self, request, *args, **kwargs): + amount = request.POST.get('amount') + if not amount or not amount.isdigit(): + messages.error(request, "Please enter a valid amount") + return self.get(request, *args, **kwargs) + + amount_cents = int(float(amount) * 100) + if amount_cents < 100: # Minimum $1 + messages.error(request, "Minimum top-up amount is $1.00") + return self.get(request, *args, **kwargs) + + # add fee (2.2% as of 2025) + amount_cents = math.ceil(amount_cents / (1 - 0.022)) + + 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 1710d6a..3b24ec1 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 0000000..f87b182 --- /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 2.2% 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 -- GitLab From e17d9b9a2c416cec153ab03064f51b1d62c284a8 Mon Sep 17 00:00:00 2001 From: bir-d <20701908+bir-d@users.noreply.github.com> Date: Fri, 20 Jun 2025 13:40:18 +0800 Subject: [PATCH 2/2] review fixes --- src/squarepay/dispense.py | 32 +++++++++++++++++++++++++------- src/squarepay/views.py | 29 ++++++++++------------------- src/templates/topup_form.html | 2 +- 3 files changed, 36 insertions(+), 27 deletions(-) diff --git a/src/squarepay/dispense.py b/src/squarepay/dispense.py index 9a50d1f..84e7f62 100644 --- a/src/squarepay/dispense.py +++ b/src/squarepay/dispense.py @@ -31,25 +31,43 @@ def run_dispense(*args): return None return res -def run_dispense_return_error(*args): +def dispense_add_balance(user, amount, id): """ - A variant of run_dispense that returns a tuple (output, error). - On success, error is None. On failure, output is None and error is the exception instance. + 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") - cmd = (DISPENSE_BIN, ) + args - log.info("run_dispense_return_error: " + str(cmd)) + 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)) - return None, e + err = e except TimeoutExpired as e: log.error(e) - return None, 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): diff --git a/src/squarepay/views.py b/src/squarepay/views.py index a7fcd6b..d257a95 100644 --- a/src/squarepay/views.py +++ b/src/squarepay/views.py @@ -17,7 +17,9 @@ 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, run_dispense_return_error +from .dispense import run_dispense, dispense_add_balance + +SQUARE_FEE = 0.022 # 2.2% as of 2025 class PaymentFormMixin: template_name = 'payment_form.html' @@ -183,22 +185,9 @@ class CustomPaymentView(MemberAccessMixin, PaymentFormMixin, DetailView): def payment_success(self, payment): super().payment_success(payment) - _, err = run_dispense_return_error("acct", payment.username, "+"+str(payment.amount), "via portal") + _, err = dispense_add_balance(payment.username, payment.amount, payment.idempotency_key) - # if dispense returned an error, coerce it into a reasonable string and store it. if err is not None: - 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}") - payment.potential_error = " | ".join(parts) if parts else err_str 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: @@ -218,21 +207,23 @@ class TopUpFormView(MemberAccessMixin, TemplateView): 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') - if not amount or not amount.isdigit(): + + try: + amount_cents = int(float(amount) * 100) + except: messages.error(request, "Please enter a valid amount") return self.get(request, *args, **kwargs) - amount_cents = int(float(amount) * 100) if amount_cents < 100: # Minimum $1 messages.error(request, "Minimum top-up amount is $1.00") return self.get(request, *args, **kwargs) - # add fee (2.2% as of 2025) - amount_cents = math.ceil(amount_cents / (1 - 0.022)) + amount_cents = math.ceil(amount_cents / (1 - SQUARE_FEE)) idempotency = uuid.uuid1() diff --git a/src/templates/topup_form.html b/src/templates/topup_form.html index f87b182..c2a856c 100644 --- a/src/templates/topup_form.html +++ b/src/templates/topup_form.html @@ -25,7 +25,7 @@ <div class="form-row"> <input type="submit" value="Continue to payment"> </div> - <em>Square charges 2.2% on API payments, which will be added to your transaction.</em> + <em>Square charges {{square_fee}}% on API payments, which will be added to your transaction.</em> </form> </div> {% endblock %} -- GitLab