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