diff --git a/src/squarepay/dispense.py b/src/squarepay/dispense.py
index 9a50d1f9d466b00e4b44bc72db37c8f412a799eb..84e7f623db8e342b557c6b86c7ad581fba786083 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 a7fcd6b0447bdec1133144e9b52896e23914657f..d257a95a5a034b1dfb1cf0ebfee7868078c04e14 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 f87b182c29c91a337af0c0167f77cb5fea41b20d..c2a856c2a7fb30a3f31bbe82b832793a28392993 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 %}