diff --git a/src/gms/settings_local.example.py b/src/gms/settings_local.example.py
index b170cb0b13802680f767a08224d75aeded60eb68..e4d608c7812e08d1fe996ae4181f94f481b3c47a 100644
--- a/src/gms/settings_local.example.py
+++ b/src/gms/settings_local.example.py
@@ -99,8 +99,12 @@ SQUARE_APP_ID = '${SQUARE_APP_ID}'
 SQUARE_LOCATION = '${SQUARE_LOCATION}'
 SQUARE_ACCESS_TOKEN = '${SQUARE_SECRET}'
 
+# path to the OpenDispense2 client binary
 DISPENSE_BIN = '/usr/local/bin/dispense'
 
+# path to the OpenDispense2 logfile
+COKELOG_PATH = ROOT_DIR + '/cokelog'
+
 # configure the email backend (see https://docs.djangoproject.com/en/2.1/topics/email/)
 EMAIL_HOST = "secure.ucc.asn.au"
 EMAIL_PORT = 465
diff --git a/src/memberdb/actions.py b/src/memberdb/actions.py
index a6c3e5a0477a20013db1fa92fb1f07f94748ffb9..82b9b8b336cb9419702d53cd3c5b649d93cde89b 100644
--- a/src/memberdb/actions.py
+++ b/src/memberdb/actions.py
@@ -4,6 +4,9 @@ from functools import wraps, singledispatch
 
 from django.db.models import FieldDoesNotExist
 from django.http import HttpResponse
+from django.contrib import messages
+
+from squarepay.cokelog import try_update_from_dispense
 
 def prep_field(obj, field):
     """
@@ -120,3 +123,21 @@ def _(description):
         return download_as_csv(modeladmin, request, queryset)
     wrapped_action.short_description = description
     return wrapped_action
+
+def refresh_dispense_payment(modeladmin, request, queryset):
+    """ update paid status from cokelog, for Membership model """
+    num_changed = 0
+    membership_list = list(queryset)
+    for ms in membership_list:
+        if ms.date_paid is not None:
+            continue
+        if try_update_from_dispense(ms):
+            ms.save()
+            num_changed += 1
+    
+    if num_changed > 0:
+        messages.success(request, "Updated %d records" % num_changed)
+    else:
+        messages.warning(request, "No records updated")
+
+refresh_dispense_payment.short_description = "Update payment status from cokelog"
\ No newline at end of file
diff --git a/src/memberdb/admin.py b/src/memberdb/admin.py
index 43e79fedf2123311776a452dbc8bfc4a728c52b6..142d0a425e3efe7308979d7f7632efbd2965c171 100644
--- a/src/memberdb/admin.py
+++ b/src/memberdb/admin.py
@@ -8,17 +8,15 @@ from django.template.loader import render_to_string
 from gms import admin
 
 from memberdb.models import Member, IncAssocMember, Membership
-from memberdb.actions import download_as_csv
+from memberdb.actions import download_as_csv, refresh_dispense_payment
 from memberdb.approve import MembershipApprovalForm, MembershipApprovalAdminView
 from memberdb.account import AccountForm, AccountView
 
 def get_model_url(pk, model_name):
 	return reverse('admin:memberdb_%s_change' % model_name, args=[pk])
 
-"""
-helper mixin to make the admin page display only "View" rather than "Change" or "Add"
-"""
 class ReadOnlyModelAdmin(admin.ModelAdmin):
+	""" helper mixin to make the admin page display only "View" rather than "Change" or "Add" """
 	def has_add_permission(self, request):
 		return False
 		
@@ -28,10 +26,10 @@ class ReadOnlyModelAdmin(admin.ModelAdmin):
 	def has_change_permission(self, request, obj=None):
 		return False
 
-"""
-Define the administrative interface for viewing member details required under the Incorporations Act
-"""
 class IAMemberAdmin(ReadOnlyModelAdmin):
+	"""
+	Define the administrative interface for viewing member details required under the Incorporations Act
+	"""
 	readonly_fields = ['__str__', 'updated', 'created']
 	fields = ['first_name', 'last_name', 'email_address', 'updated', 'created']
 	search_fields = ['first_name', 'last_name', 'email_address']
@@ -66,14 +64,11 @@ class MemberAdmin(admin.ModelAdmin):
 		]
 		return custom_urls + urls
 
-
-
 	# add a "go to member" URL into the template context data
 	def change_view(self, request, object_id, form_url='', extra_context={}):
 		extra_context['incassocmember_url'] = get_model_url(object_id, 'incassocmember')
 		return super().change_view(request, object_id, form_url, extra_context=extra_context)
 
-
 	def process_account(self, request, *args, **kwargs):
 		inst = Member.objects.get(pk=kwargs['object_id'])
 		model_dict = {
@@ -82,17 +77,16 @@ class MemberAdmin(admin.ModelAdmin):
 		}
 		return AccountView.as_view(object=inst,admin=self)(request, *args, **kwargs)
 
-
-
-"""
-Define the admin page for viewing normal Member records (all details included) and approving them
-"""
 class MembershipAdmin(admin.ModelAdmin):
+	"""
+	Define the admin page for viewing normal Member records (all details included) and approving them
+	"""
 	list_display = ['membership_info', 'membership_type', 'payment_method', 'approved', 'date_submitted', 'member_actions', ]
 	list_display_links = None
 	list_filter = ['approved']
 	readonly_fields = ['date_submitted']
 	radio_fields = {'payment_method': admin.VERTICAL, 'membership_type': admin.VERTICAL}
+	actions = [refresh_dispense_payment]
 
 	# make the admin page queryset preload the parent records (Member) 
 	def get_queryset(self, request):
@@ -143,10 +137,11 @@ class MembershipAdmin(admin.ModelAdmin):
 	def process_approve(self, request, *args, **kwargs):
 		return MembershipApprovalAdminView.as_view(admin=self)(request, *args, **kwargs)
 
-	"""
-Register multiple ModelAdmins per model. See https://stackoverflow.com/questions/2223375/multiple-modeladmins-views-for-same-model-in-django-admin/2228821
-"""
 class ProxyMembership(Membership):
+	"""
+	Register multiple ModelAdmins per model. 
+	See https://stackoverflow.com/questions/2223375/multiple-modeladmins-views-for-same-model-in-django-admin/2228821
+	"""
 	class Meta:
 		proxy = True
 
diff --git a/src/memberdb/has_paid_dispense.sh b/src/memberdb/has_paid_dispense.sh
deleted file mode 100755
index a2c2eb890013b68b4b2c3b18bd13809955e5966b..0000000000000000000000000000000000000000
--- a/src/memberdb/has_paid_dispense.sh
+++ /dev/null
@@ -1,16 +0,0 @@
-#!/bin/bash
-# script to check if a user has already paid via dispense (or purchased an item in dispense)
-# usage: $0 "$USERNAME" "$DISPENSE_ITEM_ID"
-# prints either a date like "Feb 25 17:25:23" or "None"
-
-LOG=/home/other/coke/cokelog
-USER=$1
-ITEM=$2
-
-PURCHASE=$(grep "for $USER" $LOG | grep ": dispense '" | grep "$ITEM")
-if [ "x$PURCHASE" == "x" ] || [ $(echo $PURCHASE | wc -l) -gt 1 ]; then
-	echo None
-	exit 1
-fi
-
-echo $PURCHASE | cut -c1-15
diff --git a/src/squarepay/cokelog.py b/src/squarepay/cokelog.py
new file mode 100644
index 0000000000000000000000000000000000000000..e25acf45186e3d5fd3c6ae0c5c567c1324fc11fc
--- /dev/null
+++ b/src/squarepay/cokelog.py
@@ -0,0 +1,116 @@
+""" functions to deal with the dispense cokelog, primarily to parse and determine usernames and membership type of paid members """
+import re
+from datetime import datetime
+from django.conf import settings
+
+from .payments import log
+
+COKELOG = getattr(settings, "COKELOG_PATH", None)
+if COKELOG is None:
+    log.warning("COKELOG_PATH is not defined, cannot sync payment status from dispense to DB")
+
+ALL_REGEX = r"^(?P<date>[A-Za-z]{3}\s+\d+\s[\d:]{8})\s(\w+)\sodispense2:\sdispense '([^']+)' \((?P<item>(coke|pseudo|snack|door):(\d{1,3}))\) for (?P<for>\w+) by (?P<by>\w+) \[cost\s+(\d+), balance\s+(\d+)\]$"
+MEMBERSHIP_REGEX = r"^(?P<date>[A-Za-z]{3}\s+\d+\s[\d:]{8})\s(\w+)\sodispense2:\sdispense '([^']+)' \((?P<item>(pseudo):(\d{1,3}))\) for (?P<for>\w+) by (?P<by>\w+) \[cost\s+(\d+), balance\s+(\d+)\]$"
+
+class CokeLog:
+    regex = ALL_REGEX
+
+    # dictionary (keyed by username) of lists of dispense records (by-user, and date)
+    dispenses = {}
+    filename = COKELOG
+    file = None
+    last_offset = 0
+
+    def __init__(self, **kwargs):
+        if "filename" in kwargs:
+            self.filename = kwargs.pop("filename")
+        if "regex" in kwargs:
+            self.regex = kwargs.pop("regex")
+    
+    def is_loaded(self):
+        return self.file is not None
+
+    def open(self):
+        """ loads the cokelog, trying to avoid parsing the whole thing again """
+        if not self.is_loaded():
+            # open the logfile if we haven't already
+            self.file = open(self.filename, 'r')
+        self.parse()
+
+    def reload(self):
+        if not self.is_loaded():
+            return None
+        self.parse()
+
+    def parse(self):
+        """ read the cokelog, starting where we left off """
+        pat = re.compile(self.regex)
+        year = datetime.now().year
+
+        # set file offset
+        self.file.seek(self.last_offset)
+
+        while True:
+            line = self.file.readline()
+            
+            if line == '':
+                # EOF
+                break
+
+            m = pat.match(line)
+            if m is not None:
+                data = {
+                    'by': m.group("by"),
+                    'date': datetime.strptime(m.group("date"), "%b %d %H:%M:%S").replace(year=year),
+                    'item': m.group("item"),
+                }
+                user = m.group("for")
+
+                if user in self.dispenses:
+                    self.dispenses[user] += [data]
+                else:
+                    self.dispenses[user] = [data]
+                #log.debug("got dispense item for user %s, item %s on date %s" % (user, data['item'], data['date']))
+        # remember the latest file offset
+        self.last_offset = self.file.tell()
+    
+    def get_last_dispense(self, username, item_code=None, dispense_by=None):
+        if self.dispenses is None:
+            return None
+
+        for r in reversed(self.dispenses[username]):
+            if item_code is not None and r["item"] != item_code:
+                continue
+            if dispense_by is not None and r["by"] != dispense_by:
+                continue
+            return r["date"]
+        return None
+
+# create a "static" instance of cokelog
+member_cokelog = CokeLog(regex=MEMBERSHIP_REGEX)
+
+def try_update_from_dispense(membership):
+    """
+    updates the membership with payment details from dispense, if found
+    Note: this WILL overwrite any existing payment information
+    """
+
+    # check if anything has happened since last time
+    if member_cokelog.is_loaded():
+        member_cokelog.reload()
+    else:
+        member_cokelog.open()
+    
+    # look for entries like "dispense 'membership ...' (pseudo:) for <user> by <user> ..."
+    ms_disp = member_cokelog.get_last_dispense(
+        membership.member.username,
+        membership.get_dispense_item(),
+        None,
+    )
+
+    if ms_disp is not None:
+        membership.date_paid = ms_disp
+        membership.payment_method = 'dispense'
+        return True
+    return False
+    
\ No newline at end of file