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/import_members/actions.py b/src/import_members/actions.py
index f44000792106a19f946839dba8d42fdd0aee0b08..88487a629a5fda3af0fa805889bf37d6b3595a3a 100644
--- a/src/import_members/actions.py
+++ b/src/import_members/actions.py
@@ -1,6 +1,7 @@
 from django.contrib import messages
 from django.core.exceptions import ValidationError
 from django.db import IntegrityError
+from django.db.models import Q
 
 from .models import OldMember
 from memberdb.models import Member
@@ -22,24 +23,30 @@ def import_old_member(modeladmin, request, queryset):
             nm.display_name = om.real_name
             nm.is_guild = om.guild_member
             nm.phone_number = om.phone_number
-            nm.id_number = ""
+            nm.id_number = om.student_no
+            nm.id_desc = "student"
             nm.email_address = om.email_address
             if om.membership_type == 1: # O'day special
                 # O'day special or student
-                #membership_type = 'oday'
                 nm.is_student = True
             elif om.membership_type == 2: # student
-                #membership_type = 'student_and_guild' if nm.is_guild else 'student_only'
                 nm.is_student = True
             else: # non-student
-                #membership_type = 'guild_only' if nm.is_guild else 'non_student'
                 nm.is_student = False
 
             if (nm.username == '' or nm.username is None):
                 raise ValidationError("username cannot be blank")
+
+            # try to prevent creating duplicate records on import
+            is_dupe = Q(username=nm.username) | (Q(first_name=nm.first_name) & Q(last_name=nm.last_name)) | Q(id_number=nm.id_number)
+            dupes = Member.objects.filter(is_dupe)
+            if (dupes.count() > 0):
+                raise ValidationError("suspected duplicate member record")
+                
             nm.save()
             num_success += 1
         except BaseException as e:
+            breakpoint()
             modeladmin.message_user(request, 'Could not import record (%s): %s' % (om, e), level=messages.ERROR)
         
     if (num_success > 0):
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..ab359115c08b92e25de120b0fdd71d89608137be 100644
--- a/src/memberdb/admin.py
+++ b/src/memberdb/admin.py
@@ -1,3 +1,5 @@
+from datetime import datetime
+
 from django.http import HttpResponse, HttpResponseRedirect
 from django.shortcuts import render
 from django.urls import path, reverse
@@ -8,17 +10,42 @@ 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 MembershipRenewalFilter(admin.SimpleListFilter):
+	""" allow filtering Member records by renewal year / status """
+	""" see https://docs.djangoproject.com/en/2.1/ref/contrib/admin/#django.contrib.admin.ModelAdmin.list_filter """
+	title = 'Last renewal'
+	parameter_name = 'renewed'
+
+	def lookups(self, request, modeladmin):
+		""" returns a list of tuples """
+		# hardcode the starting year, since otherwise you need a fairly complex query
+		start_year = 2019
+		year = datetime.now().year + 1
+		return [ (str(x), str(x)) for x in range(start_year, year) ] + [ ('none', 'Never renewed') ]
+	
+	def queryset(self, request, queryset):
+		""" returns the filtered queryset based on the value passed to the request """
+		if self.value() is None:
+			# return the original queryset when parameter not specified
+			return queryset
+		
+		if self.value() == 'none':
+			# filter by finding a condition that will never be true for reverse relation (child)
+			# objects that exist, since you can't filter by "has no children"
+			return queryset.filter(memberships__id__isnull=True)
+
+		# filter via attributes on children / reverse relation (hurrah!)
+		return queryset.filter(memberships__date_submitted__year=int(self.value()))
+
 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 +55,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']
@@ -52,7 +79,7 @@ class MembershipInline(admin.TabularInline):
 
 class MemberAdmin(admin.ModelAdmin):
 	list_display = ['first_name', 'last_name', 'display_name', 'username']
-	list_filter = ['is_guild', 'is_student']
+	list_filter = ['is_guild', 'is_student', MembershipRenewalFilter]
 	readonly_fields = ['member_updated', 'updated', 'created']
 	search_fields = list_display
 	actions = [download_as_csv]
@@ -66,14 +93,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 +106,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 +166,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/memberdb/register.py b/src/memberdb/register.py
index 68e3d343ce3e0b40576b37e9f2d9d2ebc3380155..0bceb6aee7a3e57051656bc9b46c6bf19368d5e7 100644
--- a/src/memberdb/register.py
+++ b/src/memberdb/register.py
@@ -1,6 +1,10 @@
 """
 This file implements the member-facing registration workflow. See ../../README.md
 """
+import subprocess
+from subprocess import CalledProcessError, TimeoutExpired
+from os import path
+from datetime import datetime
 
 from django.http import HttpResponseRedirect
 from django.shortcuts import render
@@ -10,6 +14,7 @@ from django.utils.safestring import mark_safe
 from django.utils import timezone
 from django.contrib import messages
 from django import forms
+from django.conf import settings
 
 from squarepay.models import MembershipPayment
 from squarepay.dispense import get_item_price
@@ -55,6 +60,8 @@ class RegisterRenewForm(MyModelForm):
 		m = super().save(commit=False)
 		if (m.display_name == ""):
 			m.display_name = "%s %s" % (m.first_name, m.last_name);
+		m.has_account = m.get_uid() != None
+
 		# must save otherwise membership creation will fail
 		m.save()
 
@@ -100,7 +107,7 @@ class RenewForm(RegisterRenewForm):
 	def save(self, commit=True):
 		m, ms = super().save(commit=False)
 		m.username = self.request.user.username
-		m.has_account = m.get_uid() != None
+
 		if (commit):
 			m.save()
 			ms.save()
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
diff --git a/src/squarepay/tests.py b/src/squarepay/tests.py
index 7ce503c2dd97ba78597f6ff6e4393132753573f6..be54a14313af3b9a80128b971afff3eefcd9e724 100644
--- a/src/squarepay/tests.py
+++ b/src/squarepay/tests.py
@@ -1,3 +1,29 @@
 from django.test import TestCase
+from django.conf import settings
+from datetime import datetime
 
-# Create your tests here.
+from .cokelog import CokeLog
+
+class ParseCokeLog(TestCase):
+    def test_parse_cokelog(self):
+        fn = getattr(settings, 'COKELOG_PATH', None)
+        if fn is None:
+            return
+
+        cokelog = CokeLog()
+        self.assertFalse(cokelog.is_loaded())
+        cokelog.open()
+        self.assertTrue(cokelog.is_loaded())
+
+        self.assertIsInstance(cokelog.dispenses, dict)
+        for key, val in cokelog.dispenses.items():
+            self.assertIsInstance(val, (list, tuple))
+            for record in val:
+                self.assertIsInstance(record, dict)
+                self.assertTrue('by' in record)
+                self.assertTrue('item' in record)
+                self.assertIsInstance(record['date'], datetime)
+
+        n = len(cokelog.dispenses)
+        cokelog.reload()
+        self.assertTrue(n == len(cokelog.dispenses))