diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index f99432f7b8e23c77fe87d5814ca1c4caef6a076a..c6c9dc6962180bf35063cae6e8a9f72136391892 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -45,6 +45,7 @@ deploy_testing:
     on_stop: stop_testing
   except:
       - master
+      - merge_requests
   
 deploy_staging:
   stage: deploy
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 e4d608c7812e08d1fe996ae4181f94f481b3c47a..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,19 +92,24 @@ 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}'
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/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 0bceb6aee7a3e57051656bc9b46c6bf19368d5e7..99ba5d15d136a699eaa5b079593c0d0bf5b3cf06 100644
--- a/src/memberdb/register.py
+++ b/src/memberdb/register.py
@@ -19,7 +19,7 @@ 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
 
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
index 96e26eff83632dfa28ae3ae349368ee346ac48b7..0921dae57df52ef57bb019cf58a4d5869e558e06 100644
--- a/src/squarepay/cokelog.py
+++ b/src/squarepay/cokelog.py
@@ -10,7 +10,7 @@ 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+)\]$"
+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
@@ -83,7 +83,7 @@ class CokeLog:
                 continue
             if dispense_by is not None and r["by"] != dispense_by:
                 continue
-            return r["date"]
+            return r
         return None
 
 # create a "static" instance of cokelog
@@ -105,15 +105,21 @@ def try_update_from_dispense(membership):
     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(),
-    )
+    # 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:
-        membership.date_paid = ms_disp
-        membership.payment_method = 'dispense'
-        return True
+        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
-    
\ No newline at end of file
+    
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/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 %}