Skip to content
Snippets Groups Projects
Commit 5eac02d0 authored by bird's avatar bird
Browse files

feat: add topup form to uccportal

parent 9d4f84d6
Branches
1 merge request!16feat: add topup form to uccportal
from django.utils.html import format_html from django.utils.html import format_html
from gms import admin from gms import admin
from .models import CardPayment, MembershipPayment from .models import CardPayment, MembershipPayment, TopUpPayment
class CardPaymentAdmin(admin.ModelAdmin): class CardPaymentAdmin(admin.ModelAdmin):
list_display = ['amount', 'date_created', 'is_paid'] list_display = ['amount', 'date_created', 'is_paid']
...@@ -11,5 +11,10 @@ class CardPaymentAdmin(admin.ModelAdmin): ...@@ -11,5 +11,10 @@ class CardPaymentAdmin(admin.ModelAdmin):
class MembershipPaymentAdmin(CardPaymentAdmin): class MembershipPaymentAdmin(CardPaymentAdmin):
list_display = ['amount', 'date_created', 'is_paid', 'membership'] 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(CardPayment, CardPaymentAdmin)
admin.site.register(TopUpPayment, TopUpPaymentAdmin)
admin.site.register(MembershipPayment, MembershipPaymentAdmin) admin.site.register(MembershipPayment, MembershipPaymentAdmin)
...@@ -31,6 +31,45 @@ def run_dispense(*args): ...@@ -31,6 +31,45 @@ def run_dispense(*args):
return None return None
return res 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): def get_item_price(itemid):
""" gets the price of the given dispense item in cents """ """ gets the price of the given dispense item in cents """
if (itemid is None or itemid == ""): if (itemid is None or itemid == ""):
......
...@@ -30,3 +30,10 @@ class MembershipPayment(CardPayment): ...@@ -30,3 +30,10 @@ class MembershipPayment(CardPayment):
self.membership.date_paid = timezone.now() self.membership.date_paid = timezone.now()
self.membership.payment_method = 'online' self.membership.payment_method = 'online'
super().set_paid() 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
from django.urls import path 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 # note that other apps (like memberdb) may have dependencies via the reverse URL
# using something like reverse('squarepay:pay_membership', ...) # using something like reverse('squarepay:pay_membership', ...)
...@@ -8,4 +8,6 @@ from .views import PaymentFormView, MembershipPaymentView ...@@ -8,4 +8,6 @@ from .views import PaymentFormView, MembershipPaymentView
app_name = 'squarepay' app_name = 'squarepay'
urlpatterns = [ urlpatterns = [
path('pay/<int:pk>/', MembershipPaymentView.as_view(), name='pay_membership'), 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'),
] ]
import json import json
import math
import uuid 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.views.generic.detail import DetailView
from django.http import HttpResponse, HttpResponseRedirect, Http404 from django.http import HttpResponse, HttpResponseRedirect, Http404
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
...@@ -12,10 +13,13 @@ from django.utils import timezone ...@@ -12,10 +13,13 @@ from django.utils import timezone
from memberdb.views import MemberAccessMixin from memberdb.views import MemberAccessMixin
from memberdb.models import Membership from memberdb.models import Membership
from .models import MembershipPayment, CardPayment from .models import MembershipPayment, CardPayment, TopUpPayment
from . import payments from . import payments
from .payments import try_capture_payment, log from .payments import try_capture_payment, log
from .dispense import get_item_price 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: class PaymentFormMixin:
template_name = 'payment_form.html' template_name = 'payment_form.html'
...@@ -148,3 +152,83 @@ def create_membership_payment(membership, commit=True): ...@@ -148,3 +152,83 @@ def create_membership_payment(membership, commit=True):
if (commit): if (commit):
payment.save() payment.save()
return payment 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
...@@ -39,6 +39,7 @@ ...@@ -39,6 +39,7 @@
<a class="navtab {% if url_name == 'login' %}active{% endif %}" href="{% url "memberdb:login" %}">Login</a> <a class="navtab {% if url_name == 'login' %}active{% endif %}" href="{% url "memberdb:login" %}">Login</a>
{% else %} {% else %}
<a class="navtab {% if url_name == 'renew' %}active{% endif %}" href="{% url "memberdb:renew" %}">Renew membership</a> <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 %} {% if request.user.is_staff %}
<a class="navtab {% block adminactive %}{% endblock %}" href="{% url "admin:index" %}">Admin site</a> <a class="navtab {% block adminactive %}{% endblock %}" href="{% url "admin:index" %}">Admin site</a>
......
{% 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
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment