diff --git a/README.md b/README.md
index 162d4fcb4b03e594f10adf79af019e3009880110..4cc8eea0326b5f6bc4b8eab548d18bbb67f36349 100644
--- a/README.md
+++ b/README.md
@@ -49,19 +49,18 @@ Workflow Design
 Environment Setup <a name="envsetup"></a>
 -----------------
 
-- This project uses Python 3.7
-- Install `python-virtualenv`
+- This project uses Python version >= 3.5
+- Install packages `apt-get install python3-virtualenv python3-dev build-essential libldap2-dev libsasl2-dev sqlite3`
 - `git clone https://gitlab.ucc.asn.au/frekk/uccportal uccportal`
 - `cd uccportal`
 - `virtualenv env`
 - Every time you want to do some uccportal development, do `source env/bin/activate` to set up your environment
-- Install packages needed by pip to build python dependencies: `apt-get install build-essential libldap2-dev libsasl2-dev`
 - Install python dependencies to local environment: `pip install -r pip-packages.txt`
-- Configure django: `cp gms/gms/settings_local.example.py gms/gms/settings_local.py`
-    - Edit `gms/gms/settings_local.py` and check that the database backend is configured correctly. (sqlite3 is fine for development)
-- Initialise the database: `gms/manage.py makemigrations && gms/manage.py migrate`
-    - Make sure you run this again if you make any changes to `gms/memberdb/models.py` to keep the DB schema in sync.
-- Run the local development server with `gms/manage.py runserver`
+- Configure django: `cp src/gms/settings_local.example.py src/gms/settings_local.py`
+    - Edit `src/gms/settings_local.py` and check that the database backend is configured correctly. (sqlite3 is fine for development)
+- Initialise the database: `src/manage.py makemigrations memberdb squarepay && src/manage.py migrate memberdb squarepay`
+    - Make sure you run this again if you make any changes to `src/memberdb/models.py` to keep the DB schema in sync.
+- Run the local development server with `src/manage.py runserver`
 
 -----------------------------------------------------------
 
@@ -97,9 +96,9 @@ is configured to run as an unprivileged user (ie. `www-data`)
 
     WSGIDaemonProcess uccportal python-home=/services/uccportal/env python-path=/services/uccportal/gms
     WSGIProcessGroup uccportal
-    WSGIScriptAlias / /services/uccportal/gms/gms/wsgi.py
+    WSGIScriptAlias / /services/uccportal/src/gms/wsgi.py
 
-    <Directory /services/uccportal/gms/gms>
+    <Directory /services/uccportal/src/gms>
         <Files wsgi.py>
             Require all granted
         </Files>
@@ -107,11 +106,11 @@ is configured to run as an unprivileged user (ie. `www-data`)
 
     Protocols h2 http:/1.1
 
-    <Directory /services/uccportal/gms/static>
+    <Directory /services/uccportal/media>
         Require all granted
     </Directory>
 
-    Alias /media /services/uccportal/gms/static
+    Alias /media /services/uccportal/media
 
     SSLEngine On
     SSLCertificateFile /etc/letsencrypt/live/portal.ucc.asn.au/cert.pem
@@ -124,11 +123,11 @@ is configured to run as an unprivileged user (ie. `www-data`)
 ```
 4. Configure django.
     - Follow the steps from [Environment Setup](#envsetup)
-    - `chmod 640 /services/uccportal/gms/gms/settings_local.py`
+    - `chmod 640 /services/uccportal/src/gms/settings_local.py`
     - `chgrp -R www-data /services/uccportal/`
     - `mkdir /var/log/apache2/uccportal && chgrp www-data /var/log/apache2/uccportal && chmod 775 /var/log/apache2/uccportal && chmod o+x /var/log/apache2`
     - Put the static files in the correct location for apache2 to find them:
-        - `gms/manage.py collectstatic`
+        - `src/manage.py collectstatic`
 
 
 Configuring the database backend
@@ -145,22 +144,22 @@ postgres=# CREATE USER uccportal WITH ENCRYPTED PASSWORD 'insert-password-here';
 postgres=# GRANT ALL on DATABASE uccportal to uccportal;
 ```
 
-Adjust `/services/uccportal/gms/gms/settings_local.py` to point to the new database (usually
+Adjust `/services/uccportal/src/gms/settings_local.py` to point to the new database (usually
 changing the databse name is enough).
 
 
 Making changes to data being collected
 --------------------------------------
 
-Edit `/service/uccportal/gms/memberdb/models.py`
-In `/services/uccportal/gms`, run `./manage.py makemigrations` to prepare the databae
+Edit `/service/uccportal/src/memberdb/models.py`
+In `/services/uccportal/src`, run `./manage.py makemigrations` to prepare the databae
 updates.
 
 ```
-uccportal:~# cd /services/uccportal/gms/
-uccportal:/services/uccportal/gms# ./manage.py check
+uccportal:~# cd /services/uccportal/src/
+uccportal:/services/uccportal/src# ./manage.py check
 System check identified no issues (0 silenced).
-uccportal:/services/uccportal/gms# ./manage.py migrate --run-syncdb
+uccportal:/services/uccportal/src# ./manage.py migrate --run-syncdb
 
 ...
 You just installed Django's auth system, which means you don't have any
@@ -168,7 +167,7 @@ You just installed Django's auth system, which means you don't have any
 Would you like to create one now? (yes/no): no
 
 Now restart MemberDB by runing
-uccportal:/services/uccportal/gms# touch gms/wsgi.py
+uccportal:/services/uccportal/src# touch gms/wsgi.py
 ```
 
 Now go ahead and log in to the website. It will be totally fresh, with all
@@ -186,4 +185,4 @@ from the Actions menu.
 Credits
 -------
 - Adapted from `Gumby Management System` written by David Adam <zanchey@ucc.gu.uwa.edu.au>
-- Derived from MemberDB by Danni Madeley
+- Derived from MemberDB by Danni Madeley
\ No newline at end of file
diff --git a/pip-packages.txt b/pip-packages.txt
index 1db0727343abf1e89c77d8d19d21f4812fef8214..e0807192c82ff1cb4905e1651fc1efb0a47a8301 100644
--- a/pip-packages.txt
+++ b/pip-packages.txt
@@ -1,15 +1,16 @@
-certifi==2018.11.29
-Django==2.1.5
+certifi==2019.3.9
+Django==2.2
 django-auth-ldap==1.7.0
 django-formtools==2.1
 django-sslserver==0.20
-psycopg2-binary==2.7.6.1
-pyasn1==0.4.4
-pyasn1-modules==0.2.2
-python-dateutil==2.7.5
-python-ldap==3.1.0
-pytz==2018.7
+ldap3==2.6
+psycopg2-binary==2.8.2
+pyasn1==0.4.5
+pyasn1-modules==0.2.4
+python-dateutil==2.8.0
+python-ldap==3.2.0
+pytz==2019.1
 six==1.12.0
-squareconnect==2.20181212.0
-urllib3==1.24.1
-ldap3==2.5.2
+sqlparse==0.3.0
+squareconnect==2.20190410.0
+urllib3==1.25
diff --git a/src/gms/settings.py b/src/gms/settings.py
index c86e13f50406aa236ac4b094909882090dd9cded..9dfb8a715859e6fdffa1eeafc675faf7976fc48c 100644
--- a/src/gms/settings.py
+++ b/src/gms/settings.py
@@ -129,24 +129,28 @@ LOGGING = {
 		'django': {
 			'handlers':['logfile', 'console'],
 			'propagate': True,
-			'level': LOG_LEVEL,
+			'level': LOG_LEVEL_DJANGO,
 		},
 		'django.db.backends': {
 			'handlers': ['logfile', 'console'],
-			'level': LOG_LEVEL,
+			'level': LOG_LEVEL_DJANGO,
 			'propagate': False,
 		},
 		'django.contrib.auth': {
 			'handlers': ['logfile', 'console'],
-			'level': LOG_LEVEL,
+			'level': LOG_LEVEL_DJANGO,
 		},
 		'django_auth_ldap': {
-			'level': LOG_LEVEL,
+			'level': LOG_LEVEL_DJANGO,
 			'handlers': ['logfile', 'console'],
 		},
 		'squarepay': {
 			'level': LOG_LEVEL,
 			'handlers': ['logfile', 'console'],
+		},
+		'memberdb': {
+			'level': LOG_LEVEL,
+			'handlers': ['logfile', 'console'],
 		}
 	},
 }
diff --git a/src/gms/settings_local.example.py b/src/gms/settings_local.example.py
index b170cb0b13802680f767a08224d75aeded60eb68..9be09b5d7f01fd94a4ead187e90843a65d54a26c 100644
--- a/src/gms/settings_local.example.py
+++ b/src/gms/settings_local.example.py
@@ -16,7 +16,7 @@ ADMINS = (
 ### Database connection options ###
 DATABASES = {
 	'default': {
-		'ENGINE': '${DB_ENGINE}',     # Add 'postgresql', 'mysql', 'sqlite3' or 'oracle'.
+		'ENGINE': '${DB_ENGINE}',     # django.db.backends.XXX where XXX is 'postgresql', 'mysql', 'sqlite3' or 'oracle'.
 		# this should end up in uccportal/.db/members.db
 		'NAME': '${DB_NAME}',   # Or path to database file if using sqlite3.
 		'USER': '${DB_USER}',                                 # Not used with sqlite3.
@@ -41,17 +41,12 @@ SECRET_KEY = '${APP_SECRET}'
 ALLOWED_HOSTS = ['${DEPLOY_HOST}']
 
 LOG_LEVEL = 'DEBUG'
+LOG_LEVEL_DJANGO = 'WARNING'
 LOG_FILENAME = os.path.join(ROOT_DIR, "django.log")
 
 import ldap
 from django_auth_ldap.config import LDAPSearch, ActiveDirectoryGroupType, LDAPGroupQuery
 
-# LDAP admin settings
-LDAP_BASE_DN = 'DC=ad,DC=ucc,DC=gu,DC=uwa,DC=edu,DC=au'
-LDAP_USER_SEARCH_DN = 'CN=Users,DC=ad,DC=ucc,DC=gu,DC=uwa,DC=edu,DC=au'
-LDAP_BIND_DN = 'CN=uccportal,CN=Users,DC=ad,DC=ucc,DC=gu,DC=uwa,DC=edu,DC=au'
-LDAP_BIND_SECRET = "${LDAP_SECRET}"
-
 # this could be ad.ucc.gu.uwa.edu.au but that doesn't resolve externally -
 # useful for testing, but should be changed in production so failover works
 AUTH_LDAP_SERVER_URI = 'ldaps://ad.ucc.gu.uwa.edu.au'
@@ -61,16 +56,32 @@ AUTH_LDAP_GLOBAL_OPTIONS = {
 	ldap.OPT_X_TLS_REQUIRE_CERT: ldap.OPT_X_TLS_NEVER,
 }
 
-# directly attempt to authenticate users to bind to LDAP
-AUTH_LDAP_BIND_AS_AUTHENTICATING_USER = True
+# LDAP admin settings - NOT for django_auth_ldap
+LDAP_BASE_DN = "DC=ad,DC=ucc,DC=gu,DC=uwa,DC=edu,DC=au"
+LDAP_USER_SEARCH_DN = 'CN=Users,' + LDAP_BASE_DN
+
+# settings used by memberdb LDAP backend and django_auth_ldap
+AUTH_LDAP_BIND_DN = "CN=uccportal,CN=Users," + LDAP_BASE_DN
+AUTH_LDAP_BIND_PASSWORD = "${LDAP_SECRET}"
+
+# just for django_auth_ldap
+AUTH_LDAP_BIND_AS_AUTHENTICATING_USER = False
 AUTH_LDAP_ALWAYS_UPDATE_USER = True
 AUTH_LDAP_MIRROR_GROUPS = False
 AUTH_LDAP_GROUP_TYPE = ActiveDirectoryGroupType()
-AUTH_LDAP_FIND_GROUP_PERMS = False
 
-AUTH_LDAP_USER_DN_TEMPLATE = 'CN=%(user)s,CN=Users,DC=ad,DC=ucc,DC=gu,DC=uwa,DC=edu,DC=au'
+# give user permissions from Django groups corresponding to names of AD groups
+AUTH_LDAP_FIND_GROUP_PERMS = True
+
+# speed it up by not having to search for the username, we can predict the DN
+AUTH_LDAP_USER_DN_TEMPLATE = 'CN=%(user)s,CN=Users,' + LDAP_BASE_DN
 
-AUTH_LDAP_GROUP_SEARCH = LDAPSearch("OU=Groups,DC=ad,DC=ucc,DC=gu,DC=uwa,DC=edu,DC=au",
+# this is necessary where the user DN can't be predicted, ie. if the
+# user object is named by full name rather than username
+#AUTH_LDAP_USER_SEARCH = LDAPSearch('CN=Users,' + LDAP_BASE_DN,
+#	ldap.SCOPE_SUBTREE, "(&(objectClass=user)(sAMAccountName=%(user)s))")
+
+AUTH_LDAP_GROUP_SEARCH = LDAPSearch("OU=Groups," + LDAP_BASE_DN,
 	ldap.SCOPE_SUBTREE, "(objectClass=group)")
 
 # Populate the Django user from the LDAP directory.
@@ -81,26 +92,35 @@ AUTH_LDAP_USER_ATTR_MAP = {
 	"email": "email",
 }
 
-ADMIN_ACCESS_QUERY = \
-		LDAPGroupQuery("CN=committee,OU=Groups,DC=ad,DC=ucc,DC=gu,DC=uwa,DC=edu,DC=au") | \
-		LDAPGroupQuery("CN=door,OU=Groups,DC=ad,DC=ucc,DC=gu,DC=uwa,DC=edu,DC=au") | \
-		LDAPGroupQuery("CN=wheel,OU=Groups,DC=ad,DC=ucc,DC=gu,DC=uwa,DC=edu,DC=au")
+DOOR_GROUP_QUERY = LDAPGroupQuery("CN=door,OU=Groups," + LDAP_BASE_DN)
+COMMITTEE_GROUP_QUERY = LDAPGroupQuery("CN=committee,OU=Groups," + LDAP_BASE_DN)
+WHEEL_GROUP_QUERY = LDAPGroupQuery("CN=wheel,OU=Groups," + LDAP_BASE_DN)
+
+ADMIN_ACCESS_QUERY = COMMITTEE_GROUP_QUERY | DOOR_GROUP_QUERY | WHEEL_GROUP_QUERY
 
+# assign user object flags based on group memberships (independent from permissions)
 AUTH_LDAP_USER_FLAGS_BY_GROUP = {
 	# staff can login to the admin site
 	"is_staff": ADMIN_ACCESS_QUERY,
 
 	# superusers have all permissions (but also need staff to login to admin site)
-	"is_superuser": ADMIN_ACCESS_QUERY,
+	"is_superuser": COMMITTEE_GROUP_QUERY | WHEEL_GROUP_QUERY,
 }
 
+# cache group memberships for 5 minutes
+AUTH_LDAP_CACHE_TIMEOUT = 300
+
 # the Square app and location data (set to sandbox unless you want it to charge people)
 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/account_backend.py b/src/memberdb/account_backend.py
index a2174438c451734b5803145fe23c8cb680a7c997..bcfffeddfc6f2de6353e28698025e2e7edf6855a 100644
--- a/src/memberdb/account_backend.py
+++ b/src/memberdb/account_backend.py
@@ -29,8 +29,8 @@ log = logging.getLogger('ldap')
 ldap_uri = getattr(settings, 'AUTH_LDAP_SERVER_URI')
 ldap_user_dn = getattr(settings, 'LDAP_USER_SEARCH_DN')
 ldap_base_dn = getattr(settings, 'LDAP_BASE_DN')
-ldap_bind_dn = getattr(settings, 'LDAP_BIND_DN')
-ldap_bind_secret = getattr(settings, 'LDAP_BIND_SECRET')
+ldap_bind_dn = getattr(settings, 'AUTH_LDAP_BIND_DN')
+ldap_bind_secret = getattr(settings, 'AUTH_LDAP_BIND_PASSWORD')
 make_home_cmd = ["sudo", "/services/uccportal/src/memberdb/root_actions.py"] 
 make_mail_cmd = 'ssh -i %s root@mooneye "/usr/local/mailman/bin/add_members" -r- ucc-announce <<< %s@ucc.asn.au'
 make_mail_key = './mooneye.key'
diff --git a/src/memberdb/actions.py b/src/memberdb/actions.py
index a6c3e5a0477a20013db1fa92fb1f07f94748ffb9..e7c0f99e31994bb92511b69b194d99e2cd9e91bd 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 of %d total" % (num_changed, len(membership_list)))
+    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..5b2910531c8dbe8a8d9b87598d52c65887d13ce8 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):
-	list_display = ['membership_info', 'membership_type', 'payment_method', 'approved', 'date_submitted', 'member_actions', ]
+	"""
+	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']
+	list_filter = ['approved', 'payment_method', 'membership_type', 'member__is_student', 'member__is_guild']
 	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/models.py b/src/memberdb/models.py
index b8cb469caed55544aa75c28e40c3fd0aad0aeffc..eb6b28096dab64875bb54a38daeeecc0eba57acd 100644
--- a/src/memberdb/models.py
+++ b/src/memberdb/models.py
@@ -11,52 +11,53 @@ import subprocess
 import ldap
 
 """
-dictionary of membership types & descriptions, should be updated if these are changed in dispense.
+list of membership types & descriptions, should be updated if these are changed in dispense.
+note: this is a list of tuples of key and value as dict, if it was a dict the database models get confused
 """
-MEMBERSHIP_TYPES = {
-	'oday': {
+MEMBERSHIP_TYPES = [
+	('oday', {
 		'dispense':'pseudo:11',
 		'desc':'O\' Day Special - first time members only',
 		'is_guild':True,
 		'is_student':True,
 		'must_be_fresh':True,
-	},
-	'student_and_guild': {
+	}),
+	('student_and_guild', {
 		'dispense':'pseudo:10',
 		'desc':'Student and UWA Guild member',
 		'is_guild':True,
 		'is_student':True,
 		'must_be_fresh':False,
-	},
-	'student_only': {
+	}),
+	('student_only', {
 		'dispense':'pseudo:9',
 		'desc':'Student and not UWA Guild member',
 		'is_guild':False,
 		'is_student':True,
 		'must_be_fresh':False,
-	},
-	'guild_only': {
+	}),
+	('guild_only', {
 		'dispense':'pseudo:8',
 		'desc':'Non-Student and UWA Guild member',
 		'is_guild':True,
 		'is_student':False,
 		'must_be_fresh':False,
-	},
-	'non_student': {
+	}),
+	('non_student', {
 		'dispense':'pseudo:7',
 		'desc':'Non-Student and not UWA Guild member',
 		'is_guild':False,
 		'is_student':False,
 		'must_be_fresh':False,
-	},
-	'lifer': {
+	}),
+	('lifer', {
 		'dispense':'',
 		'desc':'Life member',
 		'is_guild':False,
 		'is_student':False,
 		'must_be_fresh':False,
-	}
-}
+	})
+]
 
 def get_membership_choices(is_renew=None, get_prices=True):
 	"""
@@ -64,7 +65,7 @@ def get_membership_choices(is_renew=None, get_prices=True):
 	also dynamically fetch the prices from dispense (if possible)
 	"""
 	choices = []
-	for key, val in MEMBERSHIP_TYPES.items():
+	for key, val in MEMBERSHIP_TYPES:
 		if (val['must_be_fresh'] and is_renew == True):
 			# if you have an account already, you don't qualify for the fresher special
 			continue
@@ -90,7 +91,7 @@ def get_membership_choices(is_renew=None, get_prices=True):
 def get_membership_type(member):
 	best = 'non_student'
 	is_fresh = member.memberships.all().count() == 0
-	for i, t in MEMBERSHIP_TYPES.items():
+	for i, t in MEMBERSHIP_TYPES:
 		if (t['must_be_fresh'] == is_fresh and t['is_student'] == member.is_student and t['is_guild'] == member.is_guild):
 			best = i
 			break
@@ -225,11 +226,16 @@ class Membership (models.Model):
 	def __str__ (self):
 		return "Member [%s] (%s) renewed membership on %s" % (self.member.username, self.member.display_name, self.date_submitted.strftime("%Y-%m-%d"))
 
+	def get_membership_type(self):
+		for key, val in MEMBERSHIP_TYPES:
+			if key == self.membership_type:
+				return val
+
 	def get_dispense_item(self):
-		return MEMBERSHIP_TYPES[self.membership_type]['dispense']
+		return self.get_membership_type()['dispense']
 
 	def get_pretty_type(self):
-		return MEMBERSHIP_TYPES[self.membership_type]['desc']
+		return self.get_membership_type()['desc']
 
 	class Meta:
 		verbose_name = "Membership renewal record"
diff --git a/src/memberdb/register.py b/src/memberdb/register.py
index 68e3d343ce3e0b40576b37e9f2d9d2ebc3380155..99ba5d15d136a699eaa5b079593c0d0bf5b3cf06 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,11 +14,12 @@ 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
 
-from .models import Member, Membership, get_membership_choices, make_pending_membership, MEMBERSHIP_TYPES
+from .models import Member, Membership, get_membership_choices, make_pending_membership
 from .forms import MyModelForm
 from .views import MyUpdateView
 
@@ -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/memberdb/views.py b/src/memberdb/views.py
index 334daf71bb53d08422bed4c0ebb09e381370e8b8..19e40304f5648e01fa55512b1409ca8d0db3ebcb 100644
--- a/src/memberdb/views.py
+++ b/src/memberdb/views.py
@@ -11,7 +11,7 @@ from django.contrib.auth.mixins import AccessMixin
 from django.utils import timezone
 from formtools.wizard.views import SessionWizardView
 
-from .models import Member, IncAssocMember, Membership, MEMBERSHIP_TYPES, TokenConfirmation
+from .models import Member, IncAssocMember, Membership, TokenConfirmation
 from .forms import MemberHomeForm
 
 class MemberMiddleware:
@@ -39,6 +39,12 @@ class MemberMiddleware:
                 request.member.token = None
                 request.member.save()
 
+                if request.user.ldap_user is not None:
+                    # copy the LDAP groups so templates can access them
+                    request.member.groups = list(request.user.ldap_user.group_names)
+                else:
+                    request.member.groups = [ "gumby" ]
+
             # request.session is a dictionary-like object, its content is saved in the database
             # and only a session ID is stored as a browser cookie (by default, but is configurable)
             if 'member_id' in request.session:
diff --git a/src/squarepay/cokelog.py b/src/squarepay/cokelog.py
new file mode 100644
index 0000000000000000000000000000000000000000..0921dae57df52ef57bb019cf58a4d5869e558e06
--- /dev/null
+++ b/src/squarepay/cokelog.py
@@ -0,0 +1,125 @@
+""" 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 '(membership [^']+)' \((?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 or not username in self.dispenses:
+            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
+        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
+    """
+
+    if membership.member.username == '' or membership.member.username is None:
+        # can't do anything with empty usernames
+        return False
+
+    # 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)
+
+    if ms_disp is not None:
+        if ms_disp['item'] != membership.get_dispense_item():
+            log.warn("user '%s': paid incorrect item '%s', not '%s' in dispense." % (
+                membership.member.username, ms_disp['item'], membership.get_dispense_item()
+            ))
+        else:
+            membership.date_paid = ms_disp['date']
+            membership.payment_method = 'dispense'
+            log.debug("user '%s': paid in cokelog" % membership.member.username)
+            return True
+    else:
+        log.info("user '%s': no paid membership in cokelog" % membership.member.username)
+
+    return False
+    
diff --git a/src/squarepay/migrations/0001_initial.py b/src/squarepay/migrations/0001_initial.py
deleted file mode 100644
index 2bcf8bd51021f2f26872c739baef515a90eaa06c..0000000000000000000000000000000000000000
--- a/src/squarepay/migrations/0001_initial.py
+++ /dev/null
@@ -1,38 +0,0 @@
-# Generated by Django 2.1.5 on 2019-01-29 04:02
-
-from django.db import migrations, models
-import django.db.models.deletion
-import uuid
-
-
-class Migration(migrations.Migration):
-
-    initial = True
-
-    dependencies = [
-        ('memberdb', '__first__'),
-    ]
-
-    operations = [
-        migrations.CreateModel(
-            name='CardPayment',
-            fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('description', models.CharField(max_length=255, verbose_name='Description')),
-                ('amount', models.IntegerField(verbose_name='Amount in cents')),
-                ('idempotency_key', models.CharField(default=uuid.uuid1, max_length=64, verbose_name='Square Transactions API idempotency key')),
-                ('is_paid', models.BooleanField(blank=True, default=False, verbose_name='Has been paid')),
-                ('dispense_synced', models.BooleanField(blank=True, default=False, verbose_name='Payment logged in dispense')),
-                ('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Date created')),
-                ('date_paid', models.DateTimeField(blank=True, null=True, verbose_name='Date paid (payment captured)')),
-            ],
-        ),
-        migrations.CreateModel(
-            name='MembershipPayment',
-            fields=[
-                ('cardpayment_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='squarepay.CardPayment')),
-                ('membership', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='memberdb.Membership')),
-            ],
-            bases=('squarepay.cardpayment',),
-        ),
-    ]
diff --git a/src/squarepay/migrations/__init__.py b/src/squarepay/migrations/__init__.py
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
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))
diff --git a/src/squarepay/views.py b/src/squarepay/views.py
index 2e62b3cb500978d796c1777e2ced2ab1d72f26c7..c80825f531d6848bf3eff53cb27033cfbb58d9e6 100644
--- a/src/squarepay/views.py
+++ b/src/squarepay/views.py
@@ -9,7 +9,7 @@ from django.urls import reverse
 from django.utils import timezone
 
 from memberdb.views import MemberAccessMixin
-from memberdb.models import Membership, MEMBERSHIP_TYPES
+from memberdb.models import Membership
 
 from .models import MembershipPayment, CardPayment
 from . import payments
diff --git a/src/static/admin_custom.css b/src/static/admin_custom.css
index 00ce3db898ca8871eb330fc763d319be52f92bc7..bd13a8a1b80c494dea139c81b9ae7c459e47f488 100644
--- a/src/static/admin_custom.css
+++ b/src/static/admin_custom.css
@@ -24,17 +24,17 @@
 #header {
     padding: 0;
     height: auto;
+	display: block;
 }
 
 #admin-header {
-    padding: 0px 40px;
+    padding: 10px 40px;
 }
 
 #admin-header, #container {
     overflow: auto;
 }
 
-
 .button {
     display: inline-block;
 }
diff --git a/src/static/memberdb.css b/src/static/memberdb.css
index 004be23a6dae2550e8c7fd41ee4fd8d666860070..a5059159e31511c445593d3d2687baa63919e287 100644
--- a/src/static/memberdb.css
+++ b/src/static/memberdb.css
@@ -105,7 +105,7 @@ nav {
     overflow: auto; /* make navbar expand in case the tab buttons flow to a second row */
 }
 
-.navtab {
+.navtab, .userinfo {
     float: left;
     text-decoration: none;
     color: white;
@@ -131,6 +131,47 @@ nav {
     border-right: 1.5px solid #555;
 }
 
+.userinfo {
+	float: right;
+	border-right: 0;
+	margin-right: 0;
+	font-variant-caps: normal;
+	border: none;
+	background-color: none;
+	padding: 19px 20px;
+}
+
+.userinfo.groups {
+	padding: 7px 20px;
+}
+
+.userinfo .username {
+	display: block;
+	line-height: 22px;
+	font-size: 16px;
+	font-weight: 600;
+}
+
+/* height of group tags comes to 22px */
+.userinfo .group {
+	display: inline-block;
+	border-radius: 11px;
+	font-size: 12px;
+	line-height: 14px;
+	padding: 5px 8px 3px 8px;
+	background-color: #f6b9d8;
+	color: #000;
+	margin: 0 4px;
+}
+
+.userinfo .group.door {
+	background-color: #b9c6f6;
+}
+
+.userinfo .group.wheel {
+	background-color: #b9f6c8;
+}
+
 .member-details, .membership-details {
     width: 100%;
     text-align: left;
diff --git a/src/templates/base.html b/src/templates/base.html
index 06849d276ae778da669496f2e8a8167ad4a7ae83..1710d6a2daf95b22e08de6dc1024c4853b882a7f 100644
--- a/src/templates/base.html
+++ b/src/templates/base.html
@@ -50,6 +50,17 @@
 			<a class="navtab {% if url_name == 'logout' %}active{% endif %}" href="{% url "memberdb:logout" %}">Logout</a>
 			{% endif %}
 			{% endwith %}
+
+			{# add the groups class only if we have groups to display #}
+			<div class="userinfo{% if request.user.is_authenticated %}{% if request.member %} groups{% endif %}">
+				<span class="username">Welcome, {{ request.user.username }}</span>
+				{% if request.member %}
+				{% for g in request.member.groups %}<span class="group {{ g }}">{{ g }}</span>{% endfor %}
+				{% endif %}
+			{% else %}">
+				<span class="username">Not logged in</span>
+			{% endif %}
+			</div>
 		</nav>
 		{% endblock %}
 
diff --git a/src/templates/home.html b/src/templates/home.html
index e3f690a69a9be572bcd98190d461ef707bd448c9..63e2e520426e1116a19b0bbe08ec0ff36c2a8f58 100644
--- a/src/templates/home.html
+++ b/src/templates/home.html
@@ -15,6 +15,8 @@
     {% if request.user.is_authenticated %}
     <b>You have no member record associated with this account.</b><br>
     Please <a href="{% url 'memberdb:renew' %}">renew your membership</a> to get started.
+    <br><br>
+    Note: you may have already paid for this year, but unless the username you entered is the same as the one you are using now, it will not work.
     {% else %}
     <b>Something went wrong and your membership details could not be retrieved.</b> Please try <a href="{% url 'memberdb:login' %}">logging in</a>.
     {% endif %}