diff --git a/opentech/apply/activity/messaging.py b/opentech/apply/activity/messaging.py
index 8343c3694040ebf61dae516df709ac2c49b359cf..77a92b8a16b2f443a484d2983477f9ff8ef38e0a 100644
--- a/opentech/apply/activity/messaging.py
+++ b/opentech/apply/activity/messaging.py
@@ -18,7 +18,7 @@ User = get_user_model()
 
 
 def link_to(target, request):
-    if target:
+    if target and hasattr(target, 'get_absolute_url'):
         return request.scheme + '://' + request.get_host() + target.get_absolute_url()
 
 
@@ -55,7 +55,14 @@ neat_related = {
     MESSAGES.DELETE_REVIEW: 'review',
     MESSAGES.EDIT_REVIEW: 'review',
     MESSAGES.CREATED_PROJECT: 'submission',
+    MESSAGES.PROJECT_TRANSITION: 'old_stage',
     MESSAGES.UPDATE_PROJECT_LEAD: 'old_lead',
+    MESSAGES.APPROVE_CONTRACT: 'contract',
+    MESSAGES.UPLOAD_CONTRACT: 'contract',
+    MESSAGES.REQUEST_PAYMENT: 'payment_request',
+    MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS: 'payment_request',
+    MESSAGES.DELETE_PAYMENT_REQUEST: 'payment_request',
+    MESSAGES.UPDATE_PAYMENT_REQUEST: 'payment_request',
 }
 
 
@@ -214,11 +221,16 @@ class ActivityAdapter(AdapterBase):
         MESSAGES.OPENED_SEALED: 'Opened the submission while still sealed',
         MESSAGES.SCREENING: 'Screening status from {old_status} to {source.screening_status}',
         MESSAGES.REVIEW_OPINION: '{user} {opinion.opinion_display}s with {opinion.review.author}''s review of {source}',
-        MESSAGES.CREATED_PROJECT: '{user} has created Project',
-        MESSAGES.UPDATE_PROJECT_LEAD: 'Lead changed from {old_lead} to {source.lead} by {user}',
-        MESSAGES.SEND_FOR_APPROVAL: '{user} has requested approval',
-        MESSAGES.APPROVE_PROJECT: '{user} has approved',
-        MESSAGES.REQUEST_PROJECT_CHANGE: '{user} has requested changes to for acceptance: "{comment}"',
+        MESSAGES.CREATED_PROJECT: 'Created',
+        MESSAGES.PROJECT_TRANSITION: 'Progressed from {old_stage} to {source.status_display}',
+        MESSAGES.UPDATE_PROJECT_LEAD: 'Lead changed from {old_lead} to {source.lead}',
+        MESSAGES.SEND_FOR_APPROVAL: 'Requested approval',
+        MESSAGES.APPROVE_PROJECT: 'Approved',
+        MESSAGES.REQUEST_PROJECT_CHANGE: 'Requested changes for acceptance: "{comment}"',
+        MESSAGES.UPLOAD_CONTRACT: 'Uploaded a {contract.state} contract',
+        MESSAGES.APPROVE_CONTRACT: 'Approved contract',
+        MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS: 'Updated Payment Request status to: {payment_request.status_display}',
+        MESSAGES.REQUEST_PAYMENT: 'Payment Request submitted',
     }
 
     def recipients(self, message_type, **kwargs):
@@ -336,9 +348,10 @@ class ActivityAdapter(AdapterBase):
             except KeyError:
                 pass
 
-        # TODO resolve how related objects work with submission/project
-        has_correct_fields = all(hasattr(related, attr) for attr in ['author', 'submission', 'get_absolute_url'])
-        if has_correct_fields and isinstance(related, models.Model):
+        has_correct_fields = all(hasattr(related, attr) for attr in ['get_absolute_url'])
+        isnt_source = source != related
+        is_model = isinstance(related, models.Model)
+        if has_correct_fields and isnt_source and is_model:
             related_object = related
         else:
             related_object = None
@@ -385,6 +398,12 @@ class SlackAdapter(AdapterBase):
         MESSAGES.SEND_FOR_APPROVAL: '{user} has requested approval on project <{link}|{source.title}>.',
         MESSAGES.APPROVE_PROJECT: '{user} has approved project <{link}|{source.title}>.',
         MESSAGES.REQUEST_PROJECT_CHANGE: '{user} has requested changes for project acceptance on <{link}|{source.title}>.',
+        MESSAGES.UPLOAD_CONTRACT: '{user} has uploaded a contract for <{link}|{source.title}>.',
+        MESSAGES.APPROVE_CONTRACT: '{user} has approved contract for <{link}|{source.title}>.',
+        MESSAGES.REQUEST_PAYMENT: '{user} has requested payment for <{link}|{source.title}>.',
+        MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS: '{user} has changed the status of <{link_related}|payment request> on <{link}|{source.title}> to {payment_request.status_display}.',
+        MESSAGES.DELETE_PAYMENT_REQUEST: '{user} has deleted payment request from <{link}|{source.title}>.',
+        MESSAGES.UPDATE_PAYMENT_REQUEST: '{user} has updated payment request for <{link}|{source.title}>.',
     }
 
     def __init__(self):
@@ -402,13 +421,16 @@ class SlackAdapter(AdapterBase):
         source = kwargs['source']
         sources = kwargs['sources']
         request = kwargs['request']
+        related = kwargs['related']
         link = link_to(source, request)
+        link_related = link_to(related, request)
         links = {
             source.id: link_to(source, request)
             for source in sources
         }
         return {
             'link': link,
+            'link_related': link_related,
             'links': links,
         }
 
@@ -606,12 +628,16 @@ class EmailAdapter(AdapterBase):
         MESSAGES.READY_FOR_REVIEW: 'messages/email/ready_to_review.html',
         MESSAGES.PARTNERS_UPDATED: 'partners_updated_applicant',
         MESSAGES.PARTNERS_UPDATED_PARTNER: 'partners_updated_partner',
+        MESSAGES.UPLOAD_CONTRACT: 'messages/email/contract_uploaded.html',
+        MESSAGES.SENT_TO_COMPLIANCE: 'messages/email/sent_to_compliance.html',
+        MESSAGES.UPDATE_PAYMENT_REQUEST: 'handle_update_payment_request',
+        MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS: 'handle_payment_status_updated',
     }
 
     def get_subject(self, message_type, source):
         if source:
             if is_ready_for_review(message_type):
-                subject = 'Application ready to review: {submission.title}'.format(submission=source)
+                subject = 'Application ready to review: {source.title}'.format(source=source)
             else:
                 try:
                     subject = source.page.specific.subject or 'Your application to {org_long_name}: {source.title}'.format(org_long_name=settings.ORG_LONG_NAME, source=source)
@@ -647,6 +673,22 @@ class EmailAdapter(AdapterBase):
             old_phase = transitions[submission.id]
             return self.handle_transition(old_phase=old_phase, source=submission, **kwargs)
 
+    def handle_update_payment_request(self, user, **kwargs):
+        if user.is_applicant:
+            return
+
+        return self.render_message(
+            'messages/email/payment_request_updated.html',
+            **kwargs,
+        )
+
+    def handle_payment_status_updated(self, related, **kwargs):
+        return self.render_message(
+            'messages/email/payment_request_status_updated.html',
+            has_changes_requested=related.has_changes_requested,
+            **kwargs,
+        )
+
     def batch_determination(self, determinations, sources, **kwargs):
         submissions = sources
         kwargs.pop('source')
@@ -678,6 +720,16 @@ class EmailAdapter(AdapterBase):
             partners = kwargs['added']
             return [partner.email for partner in partners]
 
+        if message_type == MESSAGES.SENT_TO_COMPLIANCE:
+            from opentech.apply.projects.models import ProjectSettings
+            project_settings = ProjectSettings.objects.first()
+
+            if project_settings is None:
+                # TODO: what to do when this isn't configured??
+                return []
+
+            return [project_settings.compliance_email]
+
         return [source.user.email]
 
     def batch_recipients(self, message_type, sources, **kwargs):
@@ -720,11 +772,16 @@ class EmailAdapter(AdapterBase):
         return render_to_string(template, kwargs)
 
     def send_message(self, message, source, subject, recipient, logs, **kwargs):
+        try:
+            from_email = source.page.specific.from_address
+        except AttributeError:  # we're dealing with a project
+            from_email = source.submission.page.specific.from_address
+
         try:
             send_mail(
                 subject,
                 message,
-                source.page.specific.from_address,
+                from_email,
                 [recipient],
                 logs=logs
             )
@@ -740,8 +797,8 @@ class DjangoMessagesAdapter(AdapterBase):
         MESSAGES.BATCH_REVIEWERS_UPDATED: 'batch_reviewers_updated',
         MESSAGES.BATCH_TRANSITION: 'batch_transition',
         MESSAGES.BATCH_DETERMINATION_OUTCOME: 'batch_determinations',
-        MESSAGES.UPLOAD_DOCUMENT: 'Successfully uploaded document "{title}"',
-        MESSAGES.REMOVE_DOCUMENT: 'Successfully removed document "{title}"',
+        MESSAGES.UPLOAD_DOCUMENT: 'Successfully uploaded document',
+        MESSAGES.REMOVE_DOCUMENT: 'Successfully removed document',
     }
 
     def batch_reviewers_updated(self, added, sources, **kwargs):
diff --git a/opentech/apply/activity/migrations/0041_add_upload_contract.py b/opentech/apply/activity/migrations/0041_add_upload_contract.py
new file mode 100644
index 0000000000000000000000000000000000000000..6ff116ee9196e49d598ad9ad4c0971e8da0952ef
--- /dev/null
+++ b/opentech/apply/activity/migrations/0041_add_upload_contract.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.13 on 2019-08-13 09:49
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0040_merge_activity_update_and_generic_relations'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='event',
+            name='type',
+            field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project'), ('REMOVE_DOCUMENT', 'Document was Removed from Project'), ('UPLOAD_CONTRACT', 'Contract was Uploaded to Project')], max_length=50),
+        ),
+    ]
diff --git a/opentech/apply/activity/migrations/0042_add_approve_contract.py b/opentech/apply/activity/migrations/0042_add_approve_contract.py
new file mode 100644
index 0000000000000000000000000000000000000000..2eb0e19a20fa5ca4f24c581c04a303b635a26c19
--- /dev/null
+++ b/opentech/apply/activity/migrations/0042_add_approve_contract.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.13 on 2019-08-13 09:49
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0041_add_upload_contract'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='event',
+            name='type',
+            field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project'), ('REMOVE_DOCUMENT', 'Document was Removed from Project'), ('UPLOAD_CONTRACT', 'Contract was Uploaded to Project'), ('APPROVE_CONTRACT', 'Contract was Approved')], max_length=50),
+        ),
+    ]
diff --git a/opentech/apply/activity/migrations/0043_add_request_payment.py b/opentech/apply/activity/migrations/0043_add_request_payment.py
new file mode 100644
index 0000000000000000000000000000000000000000..3544f9796c621640a424567e952b57e599c9c504
--- /dev/null
+++ b/opentech/apply/activity/migrations/0043_add_request_payment.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.13 on 2019-08-15 08:59
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0042_add_approve_contract'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='event',
+            name='type',
+            field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project'), ('REMOVE_DOCUMENT', 'Document was Removed from Project'), ('UPLOAD_CONTRACT', 'Contract was Uploaded to Project'), ('APPROVE_CONTRACT', 'Contract was Approved'), ('REQUEST_PAYMENT', 'Payment was requested for Project')], max_length=50),
+        ),
+    ]
diff --git a/opentech/apply/activity/migrations/0044_add_update_payment_request_status.py b/opentech/apply/activity/migrations/0044_add_update_payment_request_status.py
new file mode 100644
index 0000000000000000000000000000000000000000..22ebb62bf29a2f42465fdb3fe1889e82c37c806b
--- /dev/null
+++ b/opentech/apply/activity/migrations/0044_add_update_payment_request_status.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.13 on 2019-08-21 15:25
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0043_add_request_payment'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='event',
+            name='type',
+            field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project'), ('REMOVE_DOCUMENT', 'Document was Removed from Project'), ('UPLOAD_CONTRACT', 'Contract was Uploaded to Project'), ('APPROVE_CONTRACT', 'Contract was Approved'), ('REQUEST_PAYMENT', 'Payment was requested for Project'), ('UPDATE_PAYMENT_REQUEST_STATUS', 'Updated Payment Request Status')], max_length=50),
+        ),
+    ]
diff --git a/opentech/apply/activity/migrations/0045_add_delete_payment_request.py b/opentech/apply/activity/migrations/0045_add_delete_payment_request.py
new file mode 100644
index 0000000000000000000000000000000000000000..394919e34a3e6545257acfd4ffeee74a1283fdcd
--- /dev/null
+++ b/opentech/apply/activity/migrations/0045_add_delete_payment_request.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.13 on 2019-08-22 07:42
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0044_add_update_payment_request_status'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='event',
+            name='type',
+            field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project'), ('REMOVE_DOCUMENT', 'Document was Removed from Project'), ('UPLOAD_CONTRACT', 'Contract was Uploaded to Project'), ('APPROVE_CONTRACT', 'Contract was Approved'), ('REQUEST_PAYMENT', 'Payment was requested for Project'), ('UPDATE_PAYMENT_REQUEST_STATUS', 'Updated Payment Request Status'), ('DELETE_PAYMENT_REQUEST', 'Delete Payment Request')], max_length=50),
+        ),
+    ]
diff --git a/opentech/apply/activity/migrations/0046_add_sent_to_compliance.py b/opentech/apply/activity/migrations/0046_add_sent_to_compliance.py
new file mode 100644
index 0000000000000000000000000000000000000000..3b9491d91dc38ab303436cbaf9f47c09b088501d
--- /dev/null
+++ b/opentech/apply/activity/migrations/0046_add_sent_to_compliance.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.13 on 2019-08-20 14:00
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0045_add_delete_payment_request'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='event',
+            name='type',
+            field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project'), ('REMOVE_DOCUMENT', 'Document was Removed from Project'), ('UPLOAD_CONTRACT', 'Contract was Uploaded to Project'), ('APPROVE_CONTRACT', 'Contract was Approved'), ('REQUEST_PAYMENT', 'Payment was requested for Project'), ('UPDATE_PAYMENT_REQUEST_STATUS', 'Updated Payment Request Status'), ('DELETE_PAYMENT_REQUEST', 'Delete Payment Request'), ('SENT_TO_COMPLIANCE', 'Project was sent to Compliance')], max_length=50),
+        ),
+    ]
diff --git a/opentech/apply/activity/migrations/0047_add_update_payment_request.py b/opentech/apply/activity/migrations/0047_add_update_payment_request.py
new file mode 100644
index 0000000000000000000000000000000000000000..07e51482ea0d51f436c6d755b81dcf5e63449310
--- /dev/null
+++ b/opentech/apply/activity/migrations/0047_add_update_payment_request.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.13 on 2019-08-23 11:57
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0046_add_sent_to_compliance'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='event',
+            name='type',
+            field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project'), ('REMOVE_DOCUMENT', 'Document was Removed from Project'), ('UPLOAD_CONTRACT', 'Contract was Uploaded to Project'), ('APPROVE_CONTRACT', 'Contract was Approved'), ('REQUEST_PAYMENT', 'Payment was requested for Project'), ('UPDATE_PAYMENT_REQUEST_STATUS', 'Updated Payment Request Status'), ('DELETE_PAYMENT_REQUEST', 'Delete Payment Request'), ('SENT_TO_COMPLIANCE', 'Project was sent to Compliance'), ('UPDATE_PAYMENT_REQUEST', 'Updated Payment Request')], max_length=50),
+        ),
+    ]
diff --git a/opentech/apply/activity/migrations/0048_add_project_transition.py b/opentech/apply/activity/migrations/0048_add_project_transition.py
new file mode 100644
index 0000000000000000000000000000000000000000..b576b1ed898cd36f307d48ee54be5225451e53da
--- /dev/null
+++ b/opentech/apply/activity/migrations/0048_add_project_transition.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.1.11 on 2019-09-05 05:58
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0047_add_update_payment_request'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='event',
+            name='type',
+            field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('PROJECT_TRANSITION', 'Project was Transitioned'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project'), ('REMOVE_DOCUMENT', 'Document was Removed from Project'), ('UPLOAD_CONTRACT', 'Contract was Uploaded to Project'), ('APPROVE_CONTRACT', 'Contract was Approved'), ('REQUEST_PAYMENT', 'Payment was requested for Project'), ('UPDATE_PAYMENT_REQUEST_STATUS', 'Updated Payment Request Status'), ('DELETE_PAYMENT_REQUEST', 'Delete Payment Request'), ('SENT_TO_COMPLIANCE', 'Project was sent to Compliance'), ('UPDATE_PAYMENT_REQUEST', 'Updated Payment Request')], max_length=50),
+        ),
+    ]
diff --git a/opentech/apply/activity/options.py b/opentech/apply/activity/options.py
index 1dacb5fe6c8b5480174f6815d95aba5dbb01e46e..7d2f9bb588d82ad1b32fe80a6a9602944adc9362 100644
--- a/opentech/apply/activity/options.py
+++ b/opentech/apply/activity/options.py
@@ -31,9 +31,17 @@ class MESSAGES(Enum):
     EDIT_REVIEW = 'Edit Review'
     SEND_FOR_APPROVAL = 'Send for Approval'
     APPROVE_PROJECT = 'Project was Approved'
+    PROJECT_TRANSITION = 'Project was Transitioned'
     REQUEST_PROJECT_CHANGE = 'Project change requested'
     UPLOAD_DOCUMENT = 'Document was Uploaded to Project'
     REMOVE_DOCUMENT = 'Document was Removed from Project'
+    UPLOAD_CONTRACT = 'Contract was Uploaded to Project'
+    APPROVE_CONTRACT = 'Contract was Approved'
+    REQUEST_PAYMENT = 'Payment was requested for Project'
+    UPDATE_PAYMENT_REQUEST_STATUS = 'Updated Payment Request Status'
+    DELETE_PAYMENT_REQUEST = 'Delete Payment Request'
+    SENT_TO_COMPLIANCE = 'Project was sent to Compliance'
+    UPDATE_PAYMENT_REQUEST = 'Updated Payment Request'
 
     @classmethod
     def choices(cls):
diff --git a/opentech/apply/activity/templates/activity/include/listing_base.html b/opentech/apply/activity/templates/activity/include/listing_base.html
index bdc55edf7ae7fa60bed5332b66916ae77fc3995e..5dce52086caa022b6ce8326272a9cac600468630 100644
--- a/opentech/apply/activity/templates/activity/include/listing_base.html
+++ b/opentech/apply/activity/templates/activity/include/listing_base.html
@@ -1,4 +1,4 @@
-{% load activity_tags bleach_tags markdown_tags submission_tags %}
+{% load activity_tags bleach_tags markdown_tags submission_tags apply_tags %}
 <div class="feed__item feed__item--{{ activity.type }}">
     <div class="feed__pre-content">
         <p class="feed__label feed__label--{{ activity.type }}">{{ activity.type|capfirst }}</p>
@@ -49,7 +49,7 @@
                 {% with url=activity.related_object.get_absolute_url %}
                     {% if url %}
                     <a href="{{ url }}" class="feed__related-item">
-                        {{ activity.related_object }} <svg><use xlink:href="#arrow-head-pixels--solid"></use></svg>
+                        {{ activity.related_object|model_verbose_name }} <svg><use xlink:href="#arrow-head-pixels--solid"></use></svg>
                     </a>
                     {% endif %}
                 {% endwith %}
diff --git a/opentech/apply/activity/templates/messages/email/contract_uploaded.html b/opentech/apply/activity/templates/messages/email/contract_uploaded.html
new file mode 100644
index 0000000000000000000000000000000000000000..5a5aeed5b6716484eb28dad517425425bfd30499
--- /dev/null
+++ b/opentech/apply/activity/templates/messages/email/contract_uploaded.html
@@ -0,0 +1,15 @@
+{% extends "messages/email/applicant_base.html" %}
+
+{% block content %}
+A new contract has been added to your Project:
+
+Title: {{ source.title }}
+Link: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }}
+
+{% if contract.is_signed %}
+This contract has already been signed and there is no action for you to take.
+{% else %}
+Please review the contract and sign it before reuploading it to your Project page for the {{ ORG_SHORT_NAME }} Team to approve.
+{% endif %}
+
+{% endblock %}
diff --git a/opentech/apply/activity/templates/messages/email/payment_request_status_updated.html b/opentech/apply/activity/templates/messages/email/payment_request_status_updated.html
new file mode 100644
index 0000000000000000000000000000000000000000..c00b187bf3c39013a3f70dd7c0b24660491bfd34
--- /dev/null
+++ b/opentech/apply/activity/templates/messages/email/payment_request_status_updated.html
@@ -0,0 +1,15 @@
+{% extends "messages/email/applicant_base.html" %}
+
+{% block content %}
+An OTF staff member has updated your payment request for {{ source.title }} for period {{ payment_request.date_from }} to {{ payment_request.date_to }}.
+It is now {{ payment_request.get_status_display }}.
+
+{% if has_changes_requested %}
+The staff member left this comment:
+
+{{ payment_request.comment }}
+{% endif %}
+
+Title: {{ source.title }}
+Link: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }}
+{% endblock %}
diff --git a/opentech/apply/activity/templates/messages/email/payment_request_updated.html b/opentech/apply/activity/templates/messages/email/payment_request_updated.html
new file mode 100644
index 0000000000000000000000000000000000000000..368b14344ad427b9d478a2c02ed7821d938101a3
--- /dev/null
+++ b/opentech/apply/activity/templates/messages/email/payment_request_updated.html
@@ -0,0 +1,9 @@
+{% extends "messages/email/applicant_base.html" %}
+
+{% block content %}
+An OTF staff member has updated your payment request for {{ source.title }} for period {{ payment_request.date_from }} to {{ payment_request.date_to }}.
+It is now {{ payment_request.get_status_display }}.
+
+Title: {{ source.title }}
+Link: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }}
+{% endblock %}
diff --git a/opentech/apply/activity/templates/messages/email/sent_to_compliance.html b/opentech/apply/activity/templates/messages/email/sent_to_compliance.html
new file mode 100644
index 0000000000000000000000000000000000000000..23b65a2bcd294704d23a002d5b35781f00fe5b11
--- /dev/null
+++ b/opentech/apply/activity/templates/messages/email/sent_to_compliance.html
@@ -0,0 +1,12 @@
+{% extends "messages/email/base.html" %}
+
+{% block salutation %}{% endblock %}
+
+{% block content %}
+A Project is awaiting your review.
+
+Title: {{ source.title }}
+Link: {{ request.scheme }}://{{ request.get_host }}{% url 'apply:projects:detail' pk=source.pk %}
+
+Please contact {{ source.lead }} - {{ source.lead.email }} if you have any questions.
+{% endblock %}
diff --git a/opentech/apply/activity/templatetags/activity_tags.py b/opentech/apply/activity/templatetags/activity_tags.py
index 9a1aa5e4e087067dbcccd49b74b4042f78827bc0..649e48387139fa0025722a3e690ebac4d6eb18bc 100644
--- a/opentech/apply/activity/templatetags/activity_tags.py
+++ b/opentech/apply/activity/templatetags/activity_tags.py
@@ -3,6 +3,7 @@ import json
 from django import template
 
 from opentech.apply.determinations.models import Determination
+from opentech.apply.projects.models import Contract
 from opentech.apply.review.models import Review
 
 from ..models import TEAM, ALL, REVIEWER
@@ -25,7 +26,7 @@ def user_can_see_related(activity, user):
     if user.is_apply_staff:
         return True
 
-    if isinstance(activity.related_object, Determination):
+    if isinstance(activity.related_object, (Determination, Contract)):
         return True
 
     return False
diff --git a/opentech/apply/activity/tests/test_messaging.py b/opentech/apply/activity/tests/test_messaging.py
index 6b6262de277e6f06d1b85181568393affaabe9d9..08cb17eebf476543c8960b23ff24331df5a48dff 100644
--- a/opentech/apply/activity/tests/test_messaging.py
+++ b/opentech/apply/activity/tests/test_messaging.py
@@ -16,8 +16,16 @@ from opentech.apply.funds.tests.factories import (
     AssignedWithRoleReviewersFactory,
 )
 from opentech.apply.review.tests.factories import ReviewFactory
-from opentech.apply.users.tests.factories import ReviewerFactory, UserFactory
-from opentech.apply.projects.tests.factories import ProjectFactory
+from opentech.apply.users.tests.factories import (
+    ApplicantFactory,
+    ReviewerFactory,
+    StaffFactory,
+    UserFactory
+)
+from opentech.apply.projects.tests.factories import (
+    ProjectFactory,
+    PaymentRequestFactory
+)
 
 from ..models import Activity, Event, Message, TEAM, ALL
 from ..messaging import (
@@ -378,7 +386,7 @@ class TestSlackAdapter(AdapterMixin, TestCase):
     @responses.activate
     def test_round_slack_channel(self):
         responses.add(responses.POST, self.target_url, status=200, body='OK')
-        submission = ApplicationSubmissionFactory(page__slack_channel='dummy')
+        submission = ApplicationSubmissionFactory(round__parent__slack_channel='dummy')
         adapter = SlackAdapter()
         message = 'my message'
         adapter.send_message(message, '', source=submission)
@@ -599,7 +607,7 @@ class TestAdaptersForProject(AdapterMixin, TestCase):
         )
         self.assertEqual(Activity.objects.count(), 1)
         activity = Activity.objects.first()
-        self.assertEqual(None, activity.related_object)
+        self.assertEqual(project.submission, activity.related_object)
 
     @override_settings(
         SLACK_DESTINATION_URL=target_url,
@@ -642,3 +650,68 @@ class TestAdaptersForProject(AdapterMixin, TestCase):
         data = json.loads(responses.calls[0].request.body)
         self.assertIn(str(user), data['message'])
         self.assertIn(str(project), data['message'])
+
+    @override_settings(
+        SLACK_DESTINATION_URL=target_url,
+        SLACK_DESTINATION_ROOM=target_room,
+    )
+    @responses.activate
+    def test_slack_applicant_update_payment_request(self):
+        responses.add(responses.POST, self.target_url, status=200, body='OK')
+
+        project = self.source_factory()
+        payment_request = PaymentRequestFactory(project=project)
+        applicant = ApplicantFactory()
+
+        self.adapter_process(
+            MESSAGES.UPDATE_PAYMENT_REQUEST,
+            adapter=self.slack(),
+            user=applicant,
+            source=project,
+            related=payment_request,
+        )
+
+        self.assertEqual(len(responses.calls), 1)
+
+        data = json.loads(responses.calls[0].request.body)
+        self.assertIn(str(applicant), data['message'])
+        self.assertIn(str(project), data['message'])
+
+    @override_settings(
+        SLACK_DESTINATION_URL=target_url,
+        SLACK_DESTINATION_ROOM=target_room,
+    )
+    @responses.activate
+    def test_slack_staff_update_payment_request(self):
+        responses.add(responses.POST, self.target_url, status=200, body='OK')
+
+        project = self.source_factory()
+        payment_request = PaymentRequestFactory(project=project)
+        staff = StaffFactory()
+
+        self.adapter_process(
+            MESSAGES.UPDATE_PAYMENT_REQUEST,
+            adapter=self.slack(),
+            user=staff,
+            source=project,
+            related=payment_request,
+        )
+
+        self.assertEqual(len(responses.calls), 1)
+
+    @override_settings(SEND_MESSAGES=True)
+    def test_email_staff_update_payment_request(self):
+        project = self.source_factory()
+        payment_request = PaymentRequestFactory(project=project)
+        staff = StaffFactory()
+
+        self.adapter_process(
+            MESSAGES.UPDATE_PAYMENT_REQUEST,
+            adapter=EmailAdapter(),
+            user=staff,
+            source=project,
+            related=payment_request,
+        )
+
+        self.assertEqual(len(mail.outbox), 1)
+        self.assertEqual(mail.outbox[0].to, [project.user.email])
diff --git a/opentech/apply/activity/views.py b/opentech/apply/activity/views.py
index c0edaf6b7abe782220d0d241b14e1e2eeddfb64d..9b7c4bb433a89fabc4f2680b5f6e4dd15b871c6f 100644
--- a/opentech/apply/activity/views.py
+++ b/opentech/apply/activity/views.py
@@ -43,12 +43,12 @@ class ActivityContextMixin:
             'actions': Activity.actions.filter(**query).select_related(
                 'user',
             ).prefetch_related(
-                'related_object__submission',
+                'related_object',
             ).visible_to(self.request.user),
             'comments': Activity.comments.filter(**query).select_related(
                 'user',
             ).prefetch_related(
-                'related_object__submission',
+                'related_object',
             ).visible_to(self.request.user),
         }
         return super().get_context_data(**extra, **kwargs)
@@ -77,7 +77,8 @@ class CommentFormView(DelegatedViewMixin, CreateView):
     def get_success_url(self):
         return self.object.source.get_absolute_url() + '#communications'
 
-    @classmethod
-    def contribute_form(cls, instance, user):
+    def get_form_kwargs(self):
         # We dont want to pass the submission as the instance
-        return super().contribute_form(instance=None, user=user)
+        kwargs = super().get_form_kwargs()
+        kwargs.pop('instance')
+        return kwargs
diff --git a/opentech/apply/categories/blocks.py b/opentech/apply/categories/blocks.py
index b8bf9a67c1180cd53a62490fae49aa46930fe6e9..e2f2d0f917aa5ee1e2e4a59a0ea792d99fdf07a9 100644
--- a/opentech/apply/categories/blocks.py
+++ b/opentech/apply/categories/blocks.py
@@ -1,5 +1,5 @@
 from django import forms
-from django.utils.functional import cached_property
+from django.utils.functional import cached_property, SimpleLazyObject
 from django.utils.translation import ugettext_lazy as _
 
 from wagtail.core.blocks import BooleanBlock, CharBlock, ChooserBlock, TextBlock
@@ -20,6 +20,10 @@ class ModelChooserBlock(ChooserBlock):
     def target_model(self):
         return resolve_model_string(self._target_model)
 
+    def to_python(self, value):
+        super_method = super().to_python
+        return SimpleLazyObject(lambda: super_method(value))
+
 
 class CategoryQuestionBlock(OptionalFormFieldBlock):
     class Meta:
diff --git a/opentech/apply/dashboard/templates/dashboard/applicant_dashboard.html b/opentech/apply/dashboard/templates/dashboard/applicant_dashboard.html
index 1c2441ac97883e465874eb42e7981680b7377671..2a74eb03d365d939d637e85d972b074e1461d8e9 100644
--- a/opentech/apply/dashboard/templates/dashboard/applicant_dashboard.html
+++ b/opentech/apply/dashboard/templates/dashboard/applicant_dashboard.html
@@ -2,14 +2,14 @@
 {% load render_table from django_tables2 %}
 {% load static wagtailcore_tags workflow_tags statusbar_tags %}
 
-{% block title %}Submission Dashboard{% endblock %}
+{% block title %}Dashboard{% endblock %}
 
 {% block content %}
 <div class="admin-bar">
     <div class="admin-bar__inner wrapper--applicant-dashboard">
         <div>
             <h3 class="heading heading--no-margin">Dashboard</h3>
-            <h5>An overview of active and past submissions</h5>
+            <h5>An overview of active and past submissions and projects</h5>
         </div>
         <div class="wrapper wrapper--cta-box">
             <h4 class="heading heading--no-margin">Submit a new application</h4>
@@ -18,6 +18,7 @@
         </div>
     </div>
 </div>
+
 <div class="wrapper wrapper--large wrapper--inner-space-medium">
     <h3>Your active submissions</h3>
     {% for submission in my_active_submissions %}
@@ -44,12 +45,27 @@
     {% endfor %}
 </div>
 
-{% if table.data %}
-    <div class="wrapper wrapper--large wrapper--inner-space-medium">
-        <h3>Submission history</h3>
-        {% render_table table %}
-    </div>
+{% if tables.0.data %}
+<div class="wrapper wrapper--large wrapper--inner-space-medium">
+    <h3>Your active projects</h3>
+    {% render_table tables.0 %}
+</div>
 {% endif %}
+
+{% if tables.1.data %}
+<div class="wrapper wrapper--large wrapper--inner-space-medium">
+    <h3>Submission history</h3>
+    {% render_table tables.1 %}
+</div>
+{% endif %}
+
+{% if tables.2.data %}
+<div class="wrapper wrapper--large wrapper--inner-space-medium">
+    <h3>Project history</h3>
+    {% render_table tables.2 %}
+</div>
+{% endif %}
+
 {% endblock %}
 
 {% block extra_js %}
diff --git a/opentech/apply/dashboard/templates/dashboard/dashboard.html b/opentech/apply/dashboard/templates/dashboard/dashboard.html
index f4c6282fb88d132ddd9545605d1ad19249b180ec..6c6f84d4e84b696b45218bd7aaa12a2ef50bfe6c 100644
--- a/opentech/apply/dashboard/templates/dashboard/dashboard.html
+++ b/opentech/apply/dashboard/templates/dashboard/dashboard.html
@@ -3,7 +3,7 @@
 {% load static %}
 
 {% block extra_css %}
-    {{ filter.form.media.css }}
+    {{ my_reviewed.filterset.form.media.css }}
 {% endblock %}
 
 {% block title %}Dashboard{% endblock %}
@@ -23,54 +23,88 @@
 <div class="wrapper wrapper--large wrapper--inner-space-medium">
 
 
-    <!-- Example Stat Block markup
-        <div class="wrapper wrapper--bottom-space">
-            <div class="stat-block">
-                <a href="#" class="stat-block__item">
-                    <p class="stat-block__number">3</p>
-                    <p class="stat-block__text">Submissions waiting for your review</p>
-                    <div class="stat-block__view">View</div>
-                </a>
-                <a href="#" class="stat-block__item">
-                    <p class="stat-block__number">10</p>
-                    <p class="stat-block__text">Live projects under your management</p>
-                    <div class="stat-block__view">View</div>
-                </a>
-                <a href="#" class="stat-block__item">
-                    <p class="stat-block__number">4</p>
-                    <p class="stat-block__text">Requests for payment requiring your attention</p>
-                    <div class="stat-block__view">View</div>
-                </a>
-            </div>
+    <div class="wrapper wrapper--bottom-space">
+        <div class="stat-block">
+            <a href="#submissions-awaiting-review" class="stat-block__item">
+                <p class="stat-block__number">{{ awaiting_reviews.count }}</p>
+                <p class="stat-block__text">Submissions waiting for your review</p>
+                <div class="stat-block__view">View</div>
+            </a>
+            <a href="#active-projects" class="stat-block__item">
+                <p class="stat-block__number">{{ projects.count }}</p>
+                <p class="stat-block__text">Live projects under your management</p>
+                <div class="stat-block__view">View</div>
+            </a>
+            {% if not projects_to_approve.count is None%}
+            <a href="#projects-awaiting-approval" class="stat-block__item">
+                <p class="stat-block__number">{{ projects_to_approve.count }}</p>
+                <p class="stat-block__text">Projects awaiting approval</p>
+                <div class="stat-block__view">View</div>
+            </a>
+            {% endif %}
+            <a href="#active-payment-requests" class="stat-block__item">
+                <p class="stat-block__number">{{ active_payment_requests.count }}</p>
+                <p class="stat-block__text">Requests for payment requiring your attention</p>
+                <div class="stat-block__view">View</div>
+            </a>
         </div>
-    -->
+    </div>
+
+    <div id="submissions-awaiting-review" class="wrapper wrapper--bottom-space">
+        {% include "dashboard/includes/waiting-for-review.html" with in_review_count=awating_reviews.count my_review=awaiting_reviews.table display_more=awaiting_reviews.display_more active_statuses_filter=awaiting_reviews.active_statuses_filter %}
+    </div>
+
+    {% if rounds.closed or rounds.open %}
+        {% include "funds/includes/round-block.html" with closed_rounds=rounds.closed open_rounds=rounds.open title="Your rounds and labs" %}
+    {% endif %}
+
+    {% if projects.table.data %}
+    <div id="active-projects" class="wrapper wrapper--bottom-space">
+        {% include "funds/includes/table_filter_and_search.html" with filter=projects.filterset filter_action=projects.url search_term=search_term search_action=projects.url search_placeholder="projects" use_search=True use_batch_actions=False heading="Your projects" %}
+        {% render_table projects.table %}
+
+        {% if projects.display_more %}
+        <div class="all-submissions-table__more">
+            <a href="{{ projects.url }}?lead={{ request.user.pk }}">Show all</a>
+        </div>
+        {% endif %}
 
-    <div class="wrapper wrapper--bottom-space">
-        {% include "dashboard/includes/waiting-for-review.html" with in_review_count=in_review_count my_review=my_review display_more=display_more active_statuses_filter=active_statuses_filter %}
     </div>
+    {% endif %}
 
-    {% if closed_rounds or open_rounds %}
-        {% include "funds/includes/round-block.html" with closed_rounds=closed_rounds open_rounds=open_rounds title=rounds_title %}
+    {% if projects_to_approve.count %}
+    <div id="projects-awaiting-approval" class="wrapper wrapper--bottom-space">
+        <h4 class="heading heading--normal">Projects awaiting approval</h4>
+        {% render_table projects_to_approve.table %}
+    </div>
     {% endif %}
 
-    {% if my_reviewed.data %}
+    {% if active_payment_requests.count %}
+    <div id="active-payment-requests" class="wrapper wrapper--bottom-space">
+        <h4 class="heading heading--normal">Active requests for payment</h4>
+        {% render_table active_payment_requests.table %}
+    </div>
+    {% endif %}
+
+    {% if my_reviewed.table.data %}
         <div class="wrapper wrapper--bottom-space">
 
-            {% include "funds/includes/table_filter_and_search.html" with filter_form=filter_form search_term=search_term use_search=True use_batch_actions=False heading="Your previous reviews" %}
-            {% render_table my_reviewed %}
+            {% include "funds/includes/table_filter_and_search.html" with filter=my_reviewed.filterset filter_action=my_reviewed.url search_term=search_term search_action=my_reviewed.url use_search=True use_batch_actions=False heading="Your previous reviews" %}
+            {% render_table my_reviewed.table %}
 
-            {% if display_more_reviewed %}
+            {% if my_reviewed.display_more %}
                 <div class="all-submissions-table__more">
-                    <a href="{% url 'apply:submissions:list' %}?reviewers={{ request.user.pk }}">Show all</a>
+                    <a href="{{ my_reviewed.url }}?reviewers={{ request.user.pk }}">Show all</a>
                 </div>
             {% endif %}
         </div>
     {% endif %}
+
 </div>
 {% endblock %}
 
 {% block extra_js %}
-    {{ filter.form.media.js }}
+    {{ my_reviewed.filterset.form.media.js }}
     <script src="https://cdnjs.cloudflare.com/ajax/libs/url-search-params/1.1.0/url-search-params.js"></script>
     <script src="{% static 'js/apply/submission-filters.js' %}"></script>
     <script src="{% static 'js/apply/submission-tooltips.js' %}"></script>
diff --git a/opentech/apply/dashboard/tests/test_views.py b/opentech/apply/dashboard/tests/test_views.py
index eb3892c58c22ce5d71845e20ee5147eb889a16c7..05b5b15ffe2d98f48a5731942213b02a0a96aa72 100644
--- a/opentech/apply/dashboard/tests/test_views.py
+++ b/opentech/apply/dashboard/tests/test_views.py
@@ -3,8 +3,26 @@ from opentech.apply.funds.tests.factories import (
     ApplicationRevisionFactory,
     InvitedToProposalFactory,
 )
+from opentech.apply.projects.models import (
+    CHANGES_REQUESTED,
+    COMMITTED,
+    DECLINED,
+    PAID,
+    SUBMITTED,
+    UNDER_REVIEW,
+)
+from opentech.apply.projects.tests.factories import (
+    PaymentRequestFactory,
+    ProjectFactory
+)
 from opentech.apply.review.tests.factories import ReviewFactory, ReviewOpinionFactory
-from opentech.apply.users.tests.factories import ApplicantFactory, ReviewerFactory, StaffFactory
+from opentech.apply.users.groups import APPROVER_GROUP_NAME
+from opentech.apply.users.tests.factories import (
+    ApplicantFactory,
+    GroupFactory,
+    ReviewerFactory,
+    StaffFactory
+)
 from opentech.apply.utils.testing.tests import BaseViewTestCase
 
 
@@ -68,7 +86,7 @@ class TestStaffDashboard(BaseViewTestCase):
         response = self.get_page()
         self.assertContains(response, 'Waiting for your review')
         self.assertContains(response, submission.title)
-        self.assertEquals(response.context['in_review_count'], 1)
+        self.assertEquals(response.context['awaiting_reviews']['count'], 1)
 
     def test_waiting_for_review_after_agreement_is_empty(self):
         staff = StaffFactory()
@@ -78,7 +96,59 @@ class TestStaffDashboard(BaseViewTestCase):
         response = self.get_page()
         self.assertContains(response, 'Waiting for your review')
         self.assertContains(response, "Nice! You're all caught up.")
-        self.assertEquals(response.context['in_review_count'], 0)
+        self.assertEquals(response.context['awaiting_reviews']['count'], 0)
+
+    def test_active_payment_requests_with_no_project(self):
+        response = self.get_page()
+        self.assertNotContains(response, "Active requests for payment")
+
+    def test_doesnt_show_active_payment_requests_with_none(self):
+        ProjectFactory(lead=self.user)
+
+        response = self.get_page()
+        self.assertNotContains(response, "Active requests for payment")
+
+    def test_doest_show_active_payment_requests_when_paid_or_declined(self):
+        project = ProjectFactory(lead=self.user)
+        PaymentRequestFactory(project=project, status=PAID)
+        PaymentRequestFactory(project=project, status=DECLINED)
+
+        response = self.get_page()
+        self.assertNotContains(response, "Active requests for payment")
+
+    def test_active_payment_requests_with_payment_requests_in_correct_state(self):
+        project = ProjectFactory(lead=self.user)
+        PaymentRequestFactory(project=project, status=SUBMITTED)
+        PaymentRequestFactory(project=project, status=CHANGES_REQUESTED)
+        PaymentRequestFactory(project=project, status=UNDER_REVIEW)
+
+        response = self.get_page()
+        self.assertContains(response, "Active requests for payment")
+
+    def test_doesnt_show_active_payment_requests_when_not_mine(self):
+        project = ProjectFactory()
+        PaymentRequestFactory(project=project, status=SUBMITTED)
+        PaymentRequestFactory(project=project, status=CHANGES_REQUESTED)
+        PaymentRequestFactory(project=project, status=UNDER_REVIEW)
+
+        response = self.get_page()
+        self.assertNotContains(response, "Active requests for payment")
+
+    def test_non_project_approver_cannot_see_projects_awaiting_review_stats_or_table(self):
+        ProjectFactory(is_locked=True, status=COMMITTED)
+
+        response = self.get_page()
+        self.assertNotContains(response, "Projects awaiting approval")
+
+    def test_project_approver_can_see_projects_awaiting_review_stats_or_table(self):
+        ProjectFactory(is_locked=True, status=COMMITTED)
+
+        user = StaffFactory()
+        user.groups.add(GroupFactory(name=APPROVER_GROUP_NAME))
+        self.client.force_login(user)
+
+        response = self.get_page()
+        self.assertContains(response, "Projects awaiting approval")
 
 
 class TestReviewerDashboard(BaseViewTestCase):
diff --git a/opentech/apply/dashboard/views.py b/opentech/apply/dashboard/views.py
index 0056650ffb2f0e73f79aa623383f572b17d396ee..148bddc9bff5fde9602e26662ecb1e5ee1a1ba56 100644
--- a/opentech/apply/dashboard/views.py
+++ b/opentech/apply/dashboard/views.py
@@ -1,8 +1,8 @@
 from django.http import HttpResponseRedirect
 from django.shortcuts import render
+from django.urls import reverse, reverse_lazy
 from django.views.generic import TemplateView
-from django.urls import reverse_lazy
-from django_tables2.views import SingleTableView
+from django_tables2.views import MultiTableMixin
 
 from opentech.apply.funds.models import ApplicationSubmission, RoundsAndLabs
 from opentech.apply.funds.tables import (
@@ -12,79 +12,112 @@ from opentech.apply.funds.tables import (
     SubmissionsTable,
     SummarySubmissionsTable,
     SummarySubmissionsTableWithRole,
-    review_filter_for_user,
+    review_filter_for_user
+)
+from opentech.apply.projects.filters import ProjectListFilter
+from opentech.apply.projects.models import (
+    PaymentRequest,
+    Project
+)
+from opentech.apply.projects.tables import (
+    PaymentRequestsDashboardTable,
+    ProjectsDashboardTable
 )
 from opentech.apply.utils.views import ViewDispatcher
 
 
 class AdminDashboardView(TemplateView):
+    template_name = 'dashboard/dashboard.html'
 
-    def get(self, request, *args, **kwargs):
-        # redirect to submissions list when we use the filter to search for something
-        if len(request.GET):
-            query_str = '?'
-            for key, value in request.GET.items():
-                query_str += key + '=' + value + '&'
-            return HttpResponseRedirect(reverse_lazy('funds:submissions:list') + query_str)
+    def get_context_data(self, **kwargs):
+        submissions = ApplicationSubmission.objects.all().for_table(self.request.user)
+
+        extra_context = {
+            'active_payment_requests': self.get_my_active_payment_requests(self.request.user),
+            'awaiting_reviews': self.get_my_awaiting_reviews(self.request.user, submissions),
+            'my_reviewed': self.get_my_reviewed(self.request, submissions),
+            'projects': self.get_my_projects(self.request),
+            'projects_to_approve': self.get_my_projects_to_approve(self.request.user),
+            'rounds': self.get_rounds(self.request.user)
+        }
+        current_context = super().get_context_data(**kwargs)
+        return {**current_context, **extra_context}
 
-        qs = ApplicationSubmission.objects.all().for_table(self.request.user)
+    def get_my_active_payment_requests(self, user):
+        payment_requests = PaymentRequest.objects.filter(
+            project__lead=user,
+        ).in_progress()
 
-        base_query = RoundsAndLabs.objects.with_progress().active().order_by('-end_date')
-        base_query = base_query.by_lead(request.user)
-        open_rounds = base_query.open()[:6]
-        open_query = '?round_state=open'
-        closed_rounds = base_query.closed()[:6]
-        closed_query = '?round_state=closed'
-        rounds_title = 'Your rounds and labs'
+        return {
+            'count': payment_requests.count(),
+            'table': PaymentRequestsDashboardTable(payment_requests),
+        }
 
-        # Staff reviewer's current to-review submissions
-        my_review_qs, my_review, display_more = self.get_my_reviews(request.user, qs)
+    def get_my_projects(self, request):
+        projects = Project.objects.filter(lead=request.user).for_table()
 
-        # Staff reviewer's reviewed submissions for 'Previous reviews' block
-        filterset, my_reviewed_qs, my_reviewed, display_more_reviewed = self.get_my_reviewed(request, qs)
+        filterset = ProjectListFilter(data=request.GET or None, request=request, queryset=projects)
 
-        # Filter for all active statuses.
-        active_statuses_filter = ''.join(f'&status={status}' for status in review_filter_for_user(request.user))
+        limit = 10
 
-        context = {
-            'open_rounds': open_rounds,
-            'open_query': open_query,
-            'closed_rounds': closed_rounds,
-            'closed_query': closed_query,
-            'rounds_title': rounds_title,
-            'my_review': my_review,
-            'in_review_count': my_review_qs.count(),
-            'display_more': display_more,
-            'my_reviewed': my_reviewed,
-            'display_more_reviewed': display_more_reviewed,
-            'filter': filterset,
-            'active_statuses_filter': active_statuses_filter,
+        return {
+            'count': projects.count(),
+            'filterset': filterset,
+            'table': ProjectsDashboardTable(projects[:limit]),
+            'display_more': projects.count() > limit,
+            'url': reverse('apply:projects:all'),
         }
 
-        return render(request, 'dashboard/dashboard.html', context)
+    def get_my_projects_to_approve(self, user):
+        if not user.is_approver:
+            return {
+                'count': None,
+                'table': None,
+            }
 
-    def get_my_reviews(self, user, qs):
-        my_review_qs = qs.in_review_for(user).order_by('-submit_time')
-        my_review_table = SummarySubmissionsTableWithRole(my_review_qs[:5], prefix='my-review-')
-        display_more = (my_review_qs.count() > 5)
+        to_approve = Project.objects.in_approval().for_table()
 
-        return my_review_qs, my_review_table, display_more
+        return {
+            'count': to_approve.count(),
+            'table': ProjectsDashboardTable(data=to_approve),
+        }
 
-    def get_my_reviewed(self, request, qs):
-        # Replicating django_filters.views.FilterView
-        my_reviewed_qs = qs.reviewed_by(request.user).order_by('-submit_time')
-        kwargs = {
-            'data': self.request.GET or None,
-            'request': self.request,
-            'queryset': my_reviewed_qs,
+    def get_my_awaiting_reviews(self, user, qs):
+        """Staff reviewer's current to-review submissions."""
+        qs = qs.in_review_for(user).order_by('-submit_time')
+        count = qs.count()
+
+        limit = 5
+        return {
+            'active_statuses_filter': ''.join(f'&status={status}' for status in review_filter_for_user(user)),
+            'count': count,
+            'display_more': count > limit,
+            'table': SummarySubmissionsTableWithRole(qs[:limit], prefix='my-review-'),
         }
-        filterset = SubmissionFilterAndSearch(**kwargs)
-        my_reviewed_qs = filterset.qs
 
-        my_reviewed_table = SummarySubmissionsTable(my_reviewed_qs[:5], prefix='my-reviewed-')
-        display_more_reviewed = (my_reviewed_qs.count() > 5)
+    def get_my_reviewed(self, request, qs):
+        """Staff reviewer's reviewed submissions for 'Previous reviews' block"""
+        qs = qs.reviewed_by(request.user).order_by('-submit_time')
 
-        return filterset, my_reviewed_qs, my_reviewed_table, display_more_reviewed
+        filterset = SubmissionFilterAndSearch(data=request.GET or None, request=request, queryset=qs)
+
+        limit = 5
+        return {
+            'filterset': filterset,
+            'table': SummarySubmissionsTable(qs[:limit], prefix='my-reviewed-'),
+            'display_more': qs.count() > limit,
+            'url': reverse('funds:submissions:list'),
+        }
+
+    def get_rounds(self, user):
+        qs = (RoundsAndLabs.objects.with_progress()
+                                   .active()
+                                   .order_by('-end_date')
+                                   .by_lead(user))
+        return {
+            'closed': qs.closed()[:6],
+            'open': qs.open()[:6],
+        }
 
 
 class ReviewerDashboardView(TemplateView):
@@ -279,29 +312,46 @@ class CommunityDashboardView(TemplateView):
         return context
 
 
-class ApplicantDashboardView(SingleTableView):
+class ApplicantDashboardView(MultiTableMixin, TemplateView):
+    tables = [
+        ProjectsDashboardTable,
+        SubmissionsTable,
+        ProjectsDashboardTable,
+    ]
     template_name = 'dashboard/applicant_dashboard.html'
-    model = ApplicationSubmission
-    table_class = SubmissionsTable
-
-    def get_queryset(self):
-        return self.model.objects.filter(
-            user=self.request.user
-        ).inactive().current().for_table(self.request.user)
 
     def get_context_data(self, **kwargs):
-        my_active_submissions = self.model.objects.filter(
-            user=self.request.user
-        ).active().current().select_related('draft_revision')
+        active_submissions = list(self.get_active_submissions(self.request.user))
 
-        my_active_submissions = [
-            submission.from_draft() for submission in my_active_submissions
-        ]
+        context = super().get_context_data(**kwargs)
+        context['my_active_submissions'] = active_submissions
+        return context
 
-        return super().get_context_data(
-            my_active_submissions=my_active_submissions,
-            **kwargs,
-        )
+    def get_active_project_data(self, user):
+        return Project.objects.filter(user=user).in_progress().for_table()
+
+    def get_active_submissions(self, user):
+        active_subs = ApplicationSubmission.objects.filter(
+            user=user,
+        ) .active().current().select_related('draft_revision')
+
+        for submission in active_subs:
+            yield submission.from_draft()
+
+    def get_historical_project_data(self, user):
+        return Project.objects.filter(user=user).complete().for_table()
+
+    def get_historical_submission_data(self, user):
+        return ApplicationSubmission.objects.filter(
+            user=user,
+        ).inactive().current().for_table(user)
+
+    def get_tables_data(self):
+        return [
+            self.get_active_project_data(self.request.user),
+            self.get_historical_submission_data(self.request.user),
+            self.get_historical_project_data(self.request.user),
+        ]
 
 
 class DashboardView(ViewDispatcher):
diff --git a/opentech/apply/determinations/tests/test_views.py b/opentech/apply/determinations/tests/test_views.py
index 03ead29bd2675e75e9b32f1a4e4f0b60509c6aa3..6632d606b611b70b80c83b63d62bf2be500e1842 100644
--- a/opentech/apply/determinations/tests/test_views.py
+++ b/opentech/apply/determinations/tests/test_views.py
@@ -270,6 +270,48 @@ class DeterminationFormTestCase(BaseViewTestCase):
         self.assertIsNone(submission_next)
         self.assertFalse(hasattr(submission_original, 'project'))
 
+    @override_settings(PROJECTS_AUTO_CREATE=False)
+    def test_disabling_project_auto_creation_stops_projects_being_created(self):
+        submission = ApplicationSubmissionFactory(
+            status='post_review_discussion',
+            workflow_stages=1,
+            lead=self.user,
+        )
+
+        self.post_page(submission, {
+            'data': 'value',
+            'outcome': ACCEPTED,
+            'message': 'You are invited to submit a proposal',
+        }, 'form')
+
+        # Cant use refresh from DB with FSM
+        submission_original = self.refresh(submission)
+        submission_next = submission_original.next
+
+        self.assertIsNone(submission_next)
+        self.assertFalse(hasattr(submission_original, 'project'))
+
+    @override_settings(PROJECTS_ENABLED=False, PROJECTS_AUTO_CREATE=True)
+    def test_disabling_projects_ignores_auto_creation_setting(self):
+        submission = ApplicationSubmissionFactory(
+            status='post_review_discussion',
+            workflow_stages=1,
+            lead=self.user,
+        )
+
+        self.post_page(submission, {
+            'data': 'value',
+            'outcome': ACCEPTED,
+            'message': 'You are invited to submit a proposal',
+        }, 'form')
+
+        # Cant use refresh from DB with FSM
+        submission_original = self.refresh(submission)
+        submission_next = submission_original.next
+
+        self.assertIsNone(submission_next)
+        self.assertFalse(hasattr(submission_original, 'project'))
+
 
 class BatchDeterminationTestCase(BaseViewTestCase):
     user_factory = StaffFactory
diff --git a/opentech/apply/determinations/views.py b/opentech/apply/determinations/views.py
index 260e6eafa576650c947bf175e7f426cc37894507..426096a1fe9393ab5088ca155ca8410992285ed5 100644
--- a/opentech/apply/determinations/views.py
+++ b/opentech/apply/determinations/views.py
@@ -1,5 +1,6 @@
 from urllib import parse
 
+from django.conf import settings
 from django.contrib import messages
 from django.contrib.auth.decorators import login_required
 from django.core.exceptions import PermissionDenied
@@ -277,7 +278,7 @@ class DeterminationCreateOrUpdateView(CreateOrUpdateView):
                 proposal_form=proposal_form,
             )
 
-            if self.submission.accepted_for_funding:
+            if self.submission.accepted_for_funding and settings.PROJECTS_AUTO_CREATE:
                 project = Project.create_from_submission(self.submission)
                 if project:
                     messenger(
diff --git a/opentech/apply/funds/files.py b/opentech/apply/funds/files.py
index 6f834375decea544af46540a69cd42b1370fae84..5c05b703476bb54f6b361c7d49d40b8d0754ede5 100644
--- a/opentech/apply/funds/files.py
+++ b/opentech/apply/funds/files.py
@@ -12,7 +12,7 @@ def generate_submission_file_path(submission_id, field_id, file_name):
     return os.path.join(path, file_name)
 
 
-class SubmissionStreamFileField(StreamFieldFile):
+class SubmissionStreamFieldFile(StreamFieldFile):
     def generate_filename(self):
         return generate_submission_file_path(self.instance.pk, self.field.id, self.name)
 
diff --git a/opentech/apply/funds/forms.py b/opentech/apply/funds/forms.py
index 84ab400ad95ebb6e153eac5099cb0441b983ac4b..df7ce6d8a9e0446ae2f674598dc59d17d65d6e06 100644
--- a/opentech/apply/funds/forms.py
+++ b/opentech/apply/funds/forms.py
@@ -139,10 +139,6 @@ class BatchUpdateSubmissionLeadForm(forms.Form):
 
     def save(self):
         new_lead = self.cleaned_data['lead']
-        import logging
-        logger = logging.getLogger('opentech')
-        logger.debug(new_lead)
-        logger.debug(new_lead.id)
         submissions = self.cleaned_data['submissions']
 
         for submission in submissions:
diff --git a/opentech/apply/funds/migrations/0068_link_funds_and_labs_to_paf.py b/opentech/apply/funds/migrations/0068_link_funds_and_labs_to_paf.py
new file mode 100644
index 0000000000000000000000000000000000000000..d82e486bc9d22bd22f115278bce93a557d450300
--- /dev/null
+++ b/opentech/apply/funds/migrations/0068_link_funds_and_labs_to_paf.py
@@ -0,0 +1,25 @@
+# Generated by Django 2.0.13 on 2019-08-11 06:01
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0018_projectapprovalform'),
+        ('funds', '0067_data_migration_for_one_form_per_stage'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='applicationbase',
+            name='approval_form',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='funds', to='application_projects.ProjectApprovalForm'),
+        ),
+        migrations.AddField(
+            model_name='labbase',
+            name='approval_form',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='labs', to='application_projects.ProjectApprovalForm'),
+        ),
+    ]
diff --git a/opentech/apply/funds/migrations/0069_merge_20190905_0403.py b/opentech/apply/funds/migrations/0069_merge_20190905_0403.py
new file mode 100644
index 0000000000000000000000000000000000000000..8e20eaf23c46e25add89713b94b50332d7032b00
--- /dev/null
+++ b/opentech/apply/funds/migrations/0069_merge_20190905_0403.py
@@ -0,0 +1,14 @@
+# Generated by Django 2.1.11 on 2019-09-05 03:03
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('funds', '0068_link_funds_and_labs_to_paf'),
+        ('funds', '0068_add_help_link_field'),
+    ]
+
+    operations = [
+    ]
diff --git a/opentech/apply/funds/models/applications.py b/opentech/apply/funds/models/applications.py
index d124036f471d785e28a8dd88f6bda14c4a9bb20a..6568029ca3665ca02c31108daf68b37b14e5b78c 100644
--- a/opentech/apply/funds/models/applications.py
+++ b/opentech/apply/funds/models/applications.py
@@ -64,6 +64,14 @@ class ApplicationBase(EmailForm, WorkflowStreamForm):  # type: ignore
         blank=True,
     )
 
+    approval_form = models.ForeignKey(
+        'application_projects.ProjectApprovalForm',
+        blank=True,
+        null=True,
+        on_delete=models.SET_NULL,
+        related_name='funds',
+    )
+
     slack_channel = models.CharField(blank=True, max_length=128, help_text=_('The slack #channel for notifications.'))
 
     objects = PageManager.from_queryset(ApplicationBaseManager)()
@@ -99,6 +107,7 @@ class ApplicationBase(EmailForm, WorkflowStreamForm):  # type: ignore
         return self.open_round.serve(request)
 
     content_panels = WorkflowStreamForm.content_panels + [
+        FieldPanel('approval_form'),
         FieldPanel('reviewers'),
         FieldPanel('slack_channel'),
     ]
@@ -382,12 +391,21 @@ class LabBase(EmailForm, WorkflowStreamForm, SubmittableStreamForm):  # type: ig
         blank=True,
     )
 
+    approval_form = models.ForeignKey(
+        'application_projects.ProjectApprovalForm',
+        blank=True,
+        null=True,
+        on_delete=models.SET_NULL,
+        related_name='labs',
+    )
+
     slack_channel = models.CharField(blank=True, max_length=128, help_text=_('The slack #channel for notifications.'))
 
     parent_page_types = ['apply_home.ApplyHomePage']
     subpage_types = []  # type: ignore
 
     content_panels = WorkflowStreamForm.content_panels + [
+        FieldPanel('approval_form'),
         FieldPanel('lead'),
         FieldPanel('reviewers'),
         FieldPanel('slack_channel'),
diff --git a/opentech/apply/funds/models/mixins.py b/opentech/apply/funds/models/mixins.py
index 03c943107ea9354dad440de83d68a9b5ac8a96cb..ddf20ac4b841e8d189a5050ed16090123c51daf3 100644
--- a/opentech/apply/funds/models/mixins.py
+++ b/opentech/apply/funds/models/mixins.py
@@ -9,7 +9,7 @@ from opentech.apply.utils.blocks import SingleIncludeMixin
 from opentech.apply.stream_forms.blocks import UploadableMediaBlock
 from opentech.apply.utils.storage import PrivateStorage
 
-from ..files import SubmissionStreamFileField
+from ..files import SubmissionStreamFieldFile
 
 __all__ = ['AccessFormData']
 
@@ -25,7 +25,7 @@ class AccessFormData:
          - form_data > jsonfield containing the submitted data
          - form_fields > streamfield containing the original form fields
     """
-    stream_file_class = SubmissionStreamFileField
+    stream_file_class = SubmissionStreamFieldFile
     storage_class = PrivateStorage
 
     @property
@@ -36,7 +36,7 @@ class AccessFormData:
         for field_name, field_id in self.named_blocks.items():
             if field_id not in data:
                 try:
-                    response = data[field_name]
+                    response = data.pop(field_name)
                 except KeyError:
                     # There was no value supplied for the named field
                     pass
@@ -67,6 +67,25 @@ class AccessFormData:
         else:
             return cls.stream_file(instance, field, file)
 
+    def process_file_data(self, data):
+        for field in self.form_fields:
+            if isinstance(field.block, UploadableMediaBlock):
+                file = self.process_file(self, field, data.get(field.id, []))
+                try:
+                    file.save()
+                except AttributeError:
+                    for f in file:
+                        f.save()
+                self.form_data[field.id] = file
+
+    def extract_files(self):
+        files = {}
+        for field in self.form_fields:
+            if isinstance(field.block, UploadableMediaBlock):
+                files[field.id] = self.data(field.id) or []
+                self.form_data.pop(field.id, None)
+        return files
+
     @classmethod
     def from_db(cls, db, field_names, values):
         instance = super().from_db(db, field_names, values)
@@ -235,5 +254,8 @@ class AccessFormData:
         # Returns a safe string of the rendered answers
         return mark_safe(''.join(self.render_answers()))
 
+    def output_text_answers(self):
+        return mark_safe(''.join(self.render_text_blocks_answers()))
+
     def output_first_group_text_answers(self):
         return mark_safe(''.join(self.render_first_group_text_answers()))
diff --git a/opentech/apply/funds/models/submissions.py b/opentech/apply/funds/models/submissions.py
index 20e91b8dd24ab43a2b5ee8dce6d74c2b8d1b25c7..a6df3d877f198d8d3b0281429afd3a173aad5ef7 100644
--- a/opentech/apply/funds/models/submissions.py
+++ b/opentech/apply/funds/models/submissions.py
@@ -36,7 +36,6 @@ from opentech.apply.categories.models import MetaCategory
 from opentech.apply.determinations.models import Determination
 from opentech.apply.review.models import ReviewOpinion
 from opentech.apply.review.options import MAYBE, AGREE, DISAGREE
-from opentech.apply.stream_forms.blocks import UploadableMediaBlock
 from opentech.apply.stream_forms.files import StreamFieldDataEncoder
 from opentech.apply.stream_forms.models import BaseStreamForm
 
@@ -579,25 +578,6 @@ class ApplicationSubmission(
             if response:
                 self.form_data[field_name] = response
 
-    def extract_files(self):
-        files = {}
-        for field in self.form_fields:
-            if isinstance(field.block, UploadableMediaBlock):
-                files[field.id] = self.data(field.id) or []
-                self.form_data.pop(field.id, None)
-        return files
-
-    def process_file_data(self, data):
-        for field in self.form_fields:
-            if isinstance(field.block, UploadableMediaBlock):
-                file = self.process_file(self, field, data.get(field.id, []))
-                try:
-                    file.save()
-                except AttributeError:
-                    for f in file:
-                        f.save()
-                self.form_data[field.id] = file
-
     def save(self, *args, update_fields=list(), skip_custom=False, **kwargs):
         if update_fields and 'form_data' not in update_fields:
             # We don't want to use this approach if the user is sending data
@@ -745,7 +725,7 @@ class ApplicationSubmission(
         return self.render_answer(name)
 
     def _get_REQUIRED_value(self, name):
-        return self.form_data[name]
+        return self.data(name)
 
 
 @receiver(post_transition, sender=ApplicationSubmission)
@@ -783,7 +763,7 @@ def log_status_update(sender, **kwargs):
         )
 
 
-class ApplicationRevision(AccessFormData, models.Model):
+class ApplicationRevision(BaseStreamForm, AccessFormData, models.Model):
     submission = models.ForeignKey(ApplicationSubmission, related_name='revisions', on_delete=models.CASCADE)
     form_data = JSONField(encoder=StreamFieldDataEncoder)
     timestamp = models.DateTimeField(auto_now=True)
diff --git a/opentech/apply/funds/paginators.py b/opentech/apply/funds/paginators.py
new file mode 100644
index 0000000000000000000000000000000000000000..bc8fd3e05c0510ca3e71026700c9df8eb557040c
--- /dev/null
+++ b/opentech/apply/funds/paginators.py
@@ -0,0 +1,113 @@
+from django.core.paginator import EmptyPage, Page, PageNotAnInteger, Paginator
+from django.utils.translation import gettext as _
+
+# https://django-tables2.readthedocs.io/en/latest/pages/api-reference.html#django_tables2.paginators.LazyPaginator
+
+# REMOVE IN django_tables2 2.0
+
+
+class LazyPaginator(Paginator):
+    """
+    Implement lazy pagination, preventing any count() queries.
+
+    By default, for any valid page, the total number of pages for the paginator will be
+
+     - `current + 1` if the number of records fetched for the current page offset is
+       bigger than the number of records per page.
+     - `current` if the number of records fetched is less than the number of records per page.
+
+    The number of additional records fetched can be adjusted using `look_ahead`, which
+    defaults to 1 page. If you like to provide a little more extra information on how much
+    pages follow the current page, you can use a higher value.
+
+    .. note::
+
+        The number of records fetched for each page is `per_page * look_ahead + 1`, so increasing
+        the value for `look_ahead` makes the view a bit more expensive.
+
+    So::
+
+        paginator = LazyPaginator(range(10000), 10)
+
+        >>> paginator.page(1).object_list
+        [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
+        >>> paginator.num_pages
+        2
+        >>> paginator.page(10).object_list
+        [91, 92, 93, 94, 95, 96, 97, 98, 99, 100]
+        >>> paginator.num_pages
+        11
+        >>> paginator.page(1000).object_list
+        [9991, 9992, 9993, 9994, 9995, 9996, 9997, 9998, 9999]
+        >>> paginator.num_pages
+        1000
+
+    Usage with `~.SingleTableView`::
+
+        class UserListView(SingleTableView):
+            table_class = UserTable
+            table_data = User.objects.all()
+            pagination_class = LazyPaginator
+
+    Or with `~.RequestConfig`::
+
+        RequestConfig(paginate={"paginator_class": LazyPaginator}).configure(table)
+
+    .. versionadded :: 2.0.0
+    """
+
+    look_ahead = 1
+
+    def __init__(self, object_list, per_page, look_ahead=None, **kwargs):
+        self._num_pages = None
+        if look_ahead is not None:
+            self.look_ahead = look_ahead
+
+        super().__init__(object_list, per_page, **kwargs)
+
+    def validate_number(self, number):
+        """Validate the given 1-based page number."""
+        try:
+            if isinstance(number, float) and not number.is_integer():
+                raise ValueError
+            number = int(number)
+        except (TypeError, ValueError):
+            raise PageNotAnInteger(_("That page number is not an integer"))
+        if number < 1:
+            raise EmptyPage(_("That page number is less than 1"))
+        return number
+
+    def page(self, number):
+        number = self.validate_number(number)
+        bottom = (number - 1) * self.per_page
+        top = bottom + self.per_page
+        # Retrieve more objects to check if there is a next page.
+        look_ahead_items = (self.look_ahead - 1) * self.per_page + 1
+        objects = list(self.object_list[bottom: top + self.orphans + look_ahead_items])
+        objects_count = len(objects)
+        if objects_count > (self.per_page + self.orphans):
+            # If another page is found, increase the total number of pages.
+            self._num_pages = number + (objects_count // self.per_page)
+            # In any case,  return only objects for this page.
+            objects = objects[: self.per_page]
+        elif (number != 1) and (objects_count <= self.orphans):
+            raise EmptyPage(_("That page contains no results"))
+        else:
+            # This is the last page.
+            self._num_pages = number
+        return Page(objects, number, self)
+
+    def _get_count(self):
+        return 0
+
+    count = property(_get_count)
+
+    def _get_num_pages(self):
+        return self._num_pages
+
+    num_pages = property(_get_num_pages)
+
+    def _get_page_range(self):
+        raise NotImplementedError
+
+    page_range = property(_get_page_range)
diff --git a/opentech/apply/funds/tables.py b/opentech/apply/funds/tables.py
index f5d5a4c1033fe9ae4ef8e7cf10ccb0cac3efcae1..90821692c36308900cd77695b5ccfbe873dc05b0 100644
--- a/opentech/apply/funds/tables.py
+++ b/opentech/apply/funds/tables.py
@@ -55,7 +55,7 @@ def render_title(record):
 
 class SubmissionsTable(tables.Table):
     """Base table for listing submissions, do not include admin data to this table"""
-    title = tables.LinkColumn('funds:submissions:detail', text=render_title, args=[A('pk')], orderable=True, attrs={'td': {'data-tooltip': lambda record: record.title, 'class': 'js-title'}})
+    title = tables.LinkColumn('funds:submissions:detail', text=render_title, args=[A('pk')], orderable=True, attrs={'td': {'data-title-tooltip': lambda record: record.title, 'class': 'js-title'}})
     submit_time = tables.DateColumn(verbose_name="Submitted")
     phase = tables.Column(verbose_name="Status", order_by=('status',), attrs={'td': {'data-actions': render_actions, 'class': 'js-actions'}})
     stage = tables.Column(verbose_name="Type", order_by=('status',))
diff --git a/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html b/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html
index 4bea7c49f804685720f6b8de0ccd94ec03193d06..e8e3b36fb373cc59d855baa6e7e02a49f9199ac5 100644
--- a/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html
+++ b/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html
@@ -70,5 +70,4 @@
     <script src="{% static 'js/apply/toggle-related.js' %}"></script>
     <script src="{% static 'js/apply/edit-comment.js' %}"></script>
     <script src="{% static 'js/apply/toggle-proposal-info.js' %}"></script>
-    <script src="{% static 'js/apply/toggle-payment-block.js' %}"></script>
 {% endblock %}
diff --git a/opentech/apply/funds/templates/funds/applicationsubmission_simplified_detail.html b/opentech/apply/funds/templates/funds/applicationsubmission_simplified_detail.html
new file mode 100644
index 0000000000000000000000000000000000000000..f0999f261d0a79375803958472e8751276b24a0f
--- /dev/null
+++ b/opentech/apply/funds/templates/funds/applicationsubmission_simplified_detail.html
@@ -0,0 +1,36 @@
+{% extends "base-apply.html" %}
+
+{% block title %}{{ object.title }}{% endblock %}
+
+{% block body_class %}{% endblock %}
+
+{% block content %}
+<div class="simplified">
+    <div class="simplified__admin-bar">
+        <div class="simplified__admin-bar-inner">
+            <a class="simplified__projects-link" href="{{ object.project.get_absolute_url }}">
+                Project
+            </a>
+            <h1 class="simplified__heading">{{ object.title }}</h1>
+            <h5 class="simplified__subheading">
+                <span>{{ object.stage }}</span>
+                <span>{{ object.page }}</span>
+                <span>{{ object.round }}</span>
+                <span>Lead: {{ object.lead }}</span>
+            </h5>
+        </div>
+    </div>
+
+    <div class="simplified__wrapper">
+        <header class="simplified__meta">
+            <span class="simplified__meta-item">Submitted: <strong>{{ object.submit_time.date }} by {{ object.user.get_full_name }}</strong></span>
+            <span class="simplified__meta-item">Last edited: <strong>{{ object.live_revision.timestamp.date }} by {{ object.live_revision.author }}</strong></span>
+        </header>
+
+        <h3>Proposal Information</h3>
+        <div class="simplified__rich-text">
+            {{ object.output_text_answers }}
+        </div>
+    </div>
+</div>
+{% endblock %}
diff --git a/opentech/apply/funds/templates/funds/includes/actions.html b/opentech/apply/funds/templates/funds/includes/actions.html
index cfd7665631b18b7448da129f75ce1fb059be85ec..0dcfc3d8ab1b46e8b71842c04a74545f8aa037e5 100644
--- a/opentech/apply/funds/templates/funds/includes/actions.html
+++ b/opentech/apply/funds/templates/funds/includes/actions.html
@@ -2,12 +2,14 @@
 
 
 {% if PROJECTS_ENABLED %}
-<a data-fancybox
-    data-src="#create-project"
-    class="button button--bottom-space button--primary button--full-width {% if object.accepted_for_funding and not object.project %}is-not-disabled{% else %}is-disabled{% endif %}"
-    href="#">
-    Create Project
-</a>
+{% if object.accepted_for_funding and not object.project %}
+    <a data-fancybox
+        data-src="#create-project"
+        class="button button--bottom-space button--primary button--full-width"
+        href="#">
+        Create Project
+    </a>
+{% endif %}
 {% endif %}
 
 <a data-fancybox data-src="#screen-application" class="button button--bottom-space button--primary button--full-width {% if screening_form.should_show %}is-not-disabled{% else %}is-disabled{% endif %}" href="#">Screen application</a>
diff --git a/opentech/apply/funds/templates/funds/includes/create_project_form.html b/opentech/apply/funds/templates/funds/includes/create_project_form.html
index f7b10c87b7925af311aea81c128017fce5b1dddd..5a0b52dc90eb678107bc5996a48bd1a7e83887d8 100644
--- a/opentech/apply/funds/templates/funds/includes/create_project_form.html
+++ b/opentech/apply/funds/templates/funds/includes/create_project_form.html
@@ -1,4 +1,6 @@
 <div class="modal" id="create-project">
     <h4 class="modal__header-bar">Create Project</h4>
-    {% include 'funds/includes/delegated_form_base.html' with form=project_form value='Create'%}
+    <p>This will create a new project and notify the Applicant.</p>
+    <p>This cannot be undone.</p>
+    {% include 'funds/includes/delegated_form_base.html' with form=project_form value='Confirm'%}
 </div>
diff --git a/opentech/apply/funds/templates/funds/includes/delegated_form_base.html b/opentech/apply/funds/templates/funds/includes/delegated_form_base.html
index 4ed3da58223d879cda16744876e5288eb8cad3bf..18292b3d89d3c371b58917a9063f0baf8a63eae0 100644
--- a/opentech/apply/funds/templates/funds/includes/delegated_form_base.html
+++ b/opentech/apply/funds/templates/funds/includes/delegated_form_base.html
@@ -1,4 +1,3 @@
-{% load util_tags %}
 <form
     class="form {{ extra_classes }}"
     method="post"
@@ -10,7 +9,10 @@
 
     {{ form.media }}
 
-    {% for field in form %}
+    {% for hidden in form.hidden_fields %}
+        {{ hidden }}
+    {% endfor %}
+    {% for field in form.visible_fields %}
         {% if field.field %}
             {% include "forms/includes/field.html" %}
         {% else %}
diff --git a/opentech/apply/funds/templates/funds/includes/funding_block.html b/opentech/apply/funds/templates/funds/includes/funding_block.html
deleted file mode 100644
index c507b379fe44078dfa14260ebca6ef8fcbeae13c..0000000000000000000000000000000000000000
--- a/opentech/apply/funds/templates/funds/includes/funding_block.html
+++ /dev/null
@@ -1,16 +0,0 @@
-<ul class="funding-block">
-    <li class="funding-block__item">
-        <p class="funding-block__title">Fund total</p>
-        <p class="funding-block__standout">$50,000</p>
-    </li>
-    <li class="funding-block__item">
-        <p class="funding-block__title">Total paid</p>
-        <p class="funding-block__standout">$2,000</p>
-        <p class="funding-block__meta">(4%)</p>
-    </li>
-    <li class="funding-block__item">
-        <p class="funding-block__title">Awaiting payment</p>
-        <p class="funding-block__standout">$10,000</p>
-        <p class="funding-block__meta">(20%)</p>
-    </li>
-</ul>
diff --git a/opentech/apply/funds/templates/funds/includes/payment_requests.html b/opentech/apply/funds/templates/funds/includes/payment_requests.html
deleted file mode 100644
index d344d17f9539db00ce09052968fdeba6e3239905..0000000000000000000000000000000000000000
--- a/opentech/apply/funds/templates/funds/includes/payment_requests.html
+++ /dev/null
@@ -1,79 +0,0 @@
-<div class="payment-block">
-    <div class="payment-block__header">
-        <p class="payment-block__title">Payment Requests</p>
-        <button class="payment-block__button button button--primary">Add Request</button>
-    </div>
-
-    <table class="payment-block__table">
-        <thead>
-            <tr>
-                <th class="payment-block__table-amount">Amount</th>
-                <th class="payment-block__table-status">Status</th>
-                <th class="payment-block__table-docs">Documents</th>
-                <th class="payment-block__table-update">Status</th>
-            </tr>
-        </thead>
-        <tbody>
-            <tr>
-                <td><span class="payment-block__mobile-label">Amount: </span>$10,000</td>
-                <td><span class="payment-block__mobile-label">Status: </span>Approved 1111-11-11</td>
-                <td><span class="payment-block__mobile-label">Documents: </span><a href="#">Download</a></td>
-                <td>
-                    <span class="payment-block__mobile-label">Status:</span>
-                    <span class="payment-block__status">Approved</span>
-                    <a data-fancybox data-src="#change-payment-status" class="payment-block__status-link" href="#">Change status</a>
-                </td>
-            </tr>
-            <tr>
-                <td><span class="payment-block__mobile-label">Amount: </span>$1,000</td>
-                <td><span class="payment-block__mobile-label">Status: </span>Approved 2222-11-11</td>
-                <td><span class="payment-block__mobile-label">Documents: </span><a href="#">Download</a></td>
-                <td>
-                    <span class="payment-block__mobile-label">Status:</span>
-                    <span class="payment-block__status">Paid</span>
-                    <a data-fancybox data-src="#change-payment-status" class="payment-block__status-link" href="#">Change status</a>
-                </td>
-            </tr>
-        </tbody>
-    </table>
-    <p class="payment-block__rejected"><a class="payment-block__rejected-link js-payment-block-rejected-link" href="#">Show rejected</a></p>
-
-    <table class="payment-block__table is-hidden js-payment-block-rejected-table">
-        <thead>
-            <tr>
-                <th class="payment-block__table-amount">Amount</th>
-                <th class="payment-block__table-status">Status</th>
-                <th class="payment-block__table-docs">Documents</th>
-                <th class="payment-block__table-update">Status</th>
-            </tr>
-        </thead>
-        <tbody>
-            <tr>
-                <td><span class="payment-block__mobile-label">Amount: </span>$14,000</td>
-                <td><span class="payment-block__mobile-label">Status: </span>Rejected 1111-11-11</td>
-                <td><span class="payment-block__mobile-label">Documents: </span><a href="#">Download</a></td>
-                <td>
-                    <span class="payment-block__mobile-label">Status:</span>
-                    <span class="payment-block__status">Rejected</span>
-                </td>
-            </tr>
-            <tr>
-                <td><span class="payment-block__mobile-label">Amount: </span>$105,000</td>
-                <td><span class="payment-block__mobile-label">Status: </span>Rejected 2222-11-11</td>
-                <td><span class="payment-block__mobile-label">Documents: </span><a href="#">Download</a></td>
-                <td>
-                    <span class="payment-block__mobile-label">Status:</span>
-                    <span class="payment-block__status">Rejected</span>
-                </td>
-            </tr>
-        </tbody>
-    </table>
-</div>
-
-<div class="modal" id="change-payment-status">
-    <h4 class="modal__header-bar">Change payment status</h4>
-    <div class="wrapper--outer-space-medium">
-        <p>Current status: <b>Approved</b></p>
-        {# {% include 'funds/includes/delegated_form_base.html' with form=some_form value='Update'%} #}
-    </div>
-</div>
diff --git a/opentech/apply/funds/templates/funds/includes/status-block.html b/opentech/apply/funds/templates/funds/includes/status-block.html
index 782e3d4999225db60eca1efa5a64a33b6493de2e..0cd33e9d7b4bcbdce2865063c53bcc20efb44863 100644
--- a/opentech/apply/funds/templates/funds/includes/status-block.html
+++ b/opentech/apply/funds/templates/funds/includes/status-block.html
@@ -5,8 +5,8 @@
             {% for status, data in status_counts.items %}
                 <li class="status-block__item">
                     <h5 class="status-block__title">{{ data.name }}</h5>
-                    <p class="status-block__info">{{ data.count }} Applications</p>
-                    <a class="status-block__link" href="{% url "funds:submissions:status" status=status %}">View</a>
+                    <p class="status-block__info">{{ data.count }} {{ type }}</p>
+                    <a class="status-block__link" href="{{ data.url }}">View</a>
                 </li>
             {% endfor %}
         </ul>
diff --git a/opentech/apply/funds/templates/funds/includes/status_bar.html b/opentech/apply/funds/templates/funds/includes/status_bar.html
index eb5235a2ec9213c99817cb3f12941e53e70e6e78..3cdde65fe9d9748399b1cf90218d5eeb3fe6c233 100644
--- a/opentech/apply/funds/templates/funds/includes/status_bar.html
+++ b/opentech/apply/funds/templates/funds/includes/status_bar.html
@@ -2,18 +2,15 @@
 <div class="status-bar {{ class }}">
     {% for phase in phases %}
         {% ifchanged phase.step %}
-            <div class="status-bar__item
-                        {% if phase.step == current_phase.step %}
-                            status-bar__item--is-current
-                        {% elif current_phase.step > phase.step %}
-                            status-bar__item--is-complete
-                        {% endif %}">
-                <span class="status-bar__tooltip"
-                    {% status_display current_phase phase public as display_text %}
-                    data-title="{{ display_text }}" aria-label="{{ display_text }}"
-                ></span>
-                <svg class="status-bar__icon"><use xlink:href="#tick-alt"></use></svg>
-            </div>
+            {% status_display current_phase phase public as display_text %}
+            {% if current_phase.step == phase.step %}
+                {% include "funds/includes/status_bar_item.html" with is_current=True is_complete=False label=display_text %}
+            {% elif current_phase.step > phase.step %}
+                {% include "funds/includes/status_bar_item.html" with is_current=False is_complete=True label=display_text %}
+            {% else %}
+                {% include "funds/includes/status_bar_item.html" with is_current=False is_complete=False label=display_text %}
+            {% endif %}
+
         {% endifchanged %}
     {% endfor %}
 </div>
diff --git a/opentech/apply/funds/templates/funds/includes/status_bar_item.html b/opentech/apply/funds/templates/funds/includes/status_bar_item.html
new file mode 100644
index 0000000000000000000000000000000000000000..051d097a3153f945cb40bf3adba0e94c7f6ca71c
--- /dev/null
+++ b/opentech/apply/funds/templates/funds/includes/status_bar_item.html
@@ -0,0 +1,12 @@
+<div class="status-bar__item
+{% if is_current %}
+    status-bar__item--is-current
+{% elif is_complete %}
+    status-bar__item--is-complete
+{% endif %}">
+    <span class="status-bar__tooltip"
+        data-title="{{ label }}" aria-label="{{ label }}"></span>
+    <svg class="status-bar__icon">
+        <use xlink:href="#tick-alt"></use>
+    </svg>
+</div>
diff --git a/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html b/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html
index 6f681f328a0e65087a31d6a1f0d73d25e1635907..de0201ac2a9efe9ea276af1d57886261656c0462 100644
--- a/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html
+++ b/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html
@@ -1,4 +1,4 @@
-<div class="wrapper wrapper--table-actions">
+<div class="wrapper wrapper--table-actions js-table-actions">
     <div class="actions-bar">
         {# Left #}
         <div class="actions-bar__inner actions-bar__inner--left">
@@ -40,11 +40,11 @@
             <button class="button button--filters js-toggle-filters">Filters</button>
 
             {% if use_search|default:False %}
-            <form method="get" role="search" class="form form--search-desktop js-search-form">
+            <form action="{{ search_action }}" method="get" role="search" class="form form--search-desktop">
                 <button class="button button--search" type="submit" aria-label="Search">
                     <svg class="icon icon--magnifying-glass icon--search"><use xlink:href="#magnifying-glass"></use></svg>
                 </button>
-                <input class="input input--search input--secondary js-search-input" type="text" placeholder="Search submissions" name="query"{% if search_term %} value="{{ search_term }}"{% endif %} aria-label="Search input">
+                <input class="input input--search input--secondary" type="text" placeholder="Search {{ search_placeholder|default:"submissions" }}" name="query"{% if search_term %} value="{{ search_term }}"{% endif %} aria-label="Search input">
             </form>
             {% endif %}
         </div>
diff --git a/opentech/apply/funds/templates/funds/submissions_overview.html b/opentech/apply/funds/templates/funds/submissions_overview.html
index 1c17a481f86da17d3eef4afe6e85ecf2c2192022..e712be96f56205853bec7ca2e7a957e0ba7af060 100644
--- a/opentech/apply/funds/templates/funds/submissions_overview.html
+++ b/opentech/apply/funds/templates/funds/submissions_overview.html
@@ -17,7 +17,7 @@
 
 <div class="wrapper wrapper--large wrapper--inner-space-medium">
 
-    {% include "funds/includes/status-block.html" %}
+    {% include "funds/includes/status-block.html" with type="Applications" %}
 
     {% if closed_rounds or open_rounds %}
         {% include "funds/includes/round-block.html" with closed_rounds=closed_rounds open_rounds=open_rounds title=rounds_title%}
diff --git a/opentech/apply/funds/templates/funds/tables/table.html b/opentech/apply/funds/templates/funds/tables/table.html
index db8cf106f06157ff258857e4a0c7ff67861cdb2b..e7048089c54b94069c7531f3d4b46c35972dde83 100644
--- a/opentech/apply/funds/templates/funds/tables/table.html
+++ b/opentech/apply/funds/templates/funds/tables/table.html
@@ -1,5 +1,5 @@
 {% extends 'django_tables2/table.html' %}
-{% load django_tables2 table_tags review_tags wagtailimages_tags %}
+{% load django_tables2 table_tags review_tags wagtailimages_tags i18n %}
 
 {% block table.tbody.row %}
     <tr {{ row.attrs.as_html }}>
@@ -118,3 +118,31 @@
 {% block table.tbody.empty_text %}
 <tr class="all-submissions-table__empty"><td colspan="{{ table.columns|length }}">{{ table.empty_text }}</td></tr>
 {% endblock table.tbody.empty_text %}
+
+{% block pagination %}
+    {% if table.page and table.paginator.num_pages > 1 %}
+    <ul class="pagination">
+        {% if table.page.has_previous %}
+            <li class="previous">
+                <a href="{% querystring table.prefixed_page_field=table.page.previous_page_number %}">
+                    {% trans 'previous' %}
+                </a>
+            </li>
+        {% endif %}
+        {% if table.page.has_previous or table.page.has_next %}
+            <li class="cardinality">
+                <p>
+                    Page {{ table.page.number }}
+                </p>
+            </li>
+        {% endif %}
+        {% if table.page.has_next %}
+            <li class="next">
+                <a href="{% querystring table.prefixed_page_field=table.page.next_page_number %}">
+                    {% trans 'next' %}
+                </a>
+            </li>
+        {% endif %}
+    </ul>
+    {% endif %}
+{% endblock pagination %}
diff --git a/opentech/apply/funds/tests/factories/blocks.py b/opentech/apply/funds/tests/factories/blocks.py
index 7d05c2fdc3e63f85e7e8c59187d7fc444038169a..643e5beb4012485b14ec5cb05059209103cbd2c9 100644
--- a/opentech/apply/funds/tests/factories/blocks.py
+++ b/opentech/apply/funds/tests/factories/blocks.py
@@ -4,13 +4,8 @@ import factory
 
 from opentech.apply.funds import blocks
 from opentech.apply.stream_forms.testing.factories import (
-    CharFieldBlockFactory,
-    FileFieldBlockFactory,
+    BLOCK_FACTORY_DEFINITION,
     FormFieldBlockFactory,
-    ImageFieldBlockFactory,
-    MultiFileFieldBlockFactory,
-    NumberFieldBlockFactory,
-    RadioFieldBlockFactory,
     ParagraphBlockFactory,
     StreamFieldUUIDFactory,
 )
@@ -64,7 +59,9 @@ class AddressFieldBlockFactory(FormFieldBlockFactory):
         model = blocks.AddressFieldBlock
 
     @classmethod
-    def make_answer(cls, params=dict()):
+    def make_answer(cls, params):
+        if not params:
+            params = {}
         return json.dumps({
             'country': 'GB',
             'thoroughfare': factory.Faker('street_name').generate(params),
@@ -76,31 +73,38 @@ class AddressFieldBlockFactory(FormFieldBlockFactory):
 
     @classmethod
     def make_form_answer(cls, params=dict()):
-        return {
-            'country': 'GB',
-            'thoroughfare': factory.Faker('street_name').generate(params),
-            'premise': factory.Faker('building_number').generate(params),
-            'locality': {
-                'localityname': factory.Faker('city').generate(params),
-                'administrativearea': factory.Faker('city').generate(params),
-                'postal_code': 'SW1 4AQ',
+        try:
+            address = json.loads(params)
+        except TypeError:
+            if not params:
+                params = {}
+            return {
+                'country': 'GB',
+                'thoroughfare': factory.Faker('street_name').generate(params),
+                'premise': factory.Faker('building_number').generate(params),
+                'locality': {
+                    'localityname': factory.Faker('city').generate(params),
+                    'administrativearea': factory.Faker('city').generate(params),
+                    'postal_code': 'SW1 4AQ',
+                }
             }
+
+        address['locality'] = {
+            'localityname': address.pop('localityname'),
+            'administrativearea': address.pop('administrativearea'),
+            'postalcode': address.pop('postalcode'),
         }
+        return address
 
 
 CustomFormFieldsFactory = StreamFieldUUIDFactory({
+    **BLOCK_FACTORY_DEFINITION,
     'duration': DurationBlockFactory,
     'title': TitleBlockFactory,
     'value': ValueFieldBlockFactory,
     'email': EmailBlockFactory,
     'address': AddressFieldBlockFactory,
     'full_name': FullNameBlockFactory,
-    'char': CharFieldBlockFactory,
-    'number': NumberFieldBlockFactory,
-    'radios': RadioFieldBlockFactory,
-    'rich_text': RichTextFieldBlockFactory,
-    'image': ImageFieldBlockFactory,
-    'file': FileFieldBlockFactory,
-    'multi_file': MultiFileFieldBlockFactory,
     'text_markup': ParagraphBlockFactory,
+    'rich_text': RichTextFieldBlockFactory,
 })
diff --git a/opentech/apply/funds/tests/factories/models.py b/opentech/apply/funds/tests/factories/models.py
index 6b348db8f04cd1b3c1b190a4e2966c25ea3eb794..20fd24800615cf1fa837906d852bf1b63e402823 100644
--- a/opentech/apply/funds/tests/factories/models.py
+++ b/opentech/apply/funds/tests/factories/models.py
@@ -75,6 +75,7 @@ class AbstractApplicationFactory(wagtail_factories.PageFactory):
 
     # Will need to update how the stages are identified as Fund Page changes
     workflow_name = factory.LazyAttribute(lambda o: workflow_for_stages(o.workflow_stages))
+    approval_form = factory.SubFactory('opentech.apply.projects.tests.factories.ProjectApprovalFormFactory')
 
     @factory.post_generation
     def parent(self, create, extracted_parent, **parent_kwargs):
@@ -237,7 +238,7 @@ class ApplicationSubmissionFactory(factory.DjangoModelFactory):
         ApplicationFormDataFactory,
         form_fields=factory.SelfAttribute('..form_fields'),
     )
-    page = factory.SubFactory(FundTypeFactory)
+    page = factory.SelfAttribute('.round.fund')
     workflow_name = factory.LazyAttribute(lambda o: workflow_for_stages(o.workflow_stages))
     round = factory.SubFactory(
         RoundFactory,
diff --git a/opentech/apply/funds/tests/test_admin_form.py b/opentech/apply/funds/tests/test_admin_form.py
index 244ece50b13c2f5b9269b9f22a79e18ffa26856e..d256c41ed86a18fbb39c651919238acc7604f028 100644
--- a/opentech/apply/funds/tests/test_admin_form.py
+++ b/opentech/apply/funds/tests/test_admin_form.py
@@ -49,6 +49,7 @@ def form_data(num_appl_forms=0, num_review_forms=0, delete=0, stages=1, same_for
     fund_data['workflow_name'] = workflow_for_stages(stages)
 
     form_data.update(fund_data)
+    form_data.update(approval_form='')
     return form_data
 
 
diff --git a/opentech/apply/funds/tests/test_admin_views.py b/opentech/apply/funds/tests/test_admin_views.py
index 185a0e47bbbc7f50c850a5911535f0127b7fb520..bb983563f5689d0604080aa9ac82b70d1afddfd7 100644
--- a/opentech/apply/funds/tests/test_admin_views.py
+++ b/opentech/apply/funds/tests/test_admin_views.py
@@ -22,11 +22,21 @@ class TestFundCreationView(TestCase):
         url = reverse('wagtailadmin_pages:add', args=('funds', 'fundtype', self.home.id))
 
         data = form_data(
-            appl_forms, review_forms, same_forms=same_forms, stages=stages, form_stage_info=form_stage_info)
+            appl_forms,
+            review_forms,
+            same_forms=same_forms,
+            stages=stages,
+            form_stage_info=form_stage_info,
+        )
         data['action-publish'] = True
 
         response = self.client.post(url, data=data, secure=True, follow=True)
-        self.assertContains(response, 'success')
+        try:
+            # If the form is present there was an error - report it
+            form = response.context['form']
+            self.assertTrue(form.is_valid(), form.errors.as_text())
+        except KeyError:
+            self.assertContains(response, 'success')
 
         self.home.refresh_from_db()
         fund = self.home.get_children().first()
diff --git a/opentech/apply/funds/tests/test_models.py b/opentech/apply/funds/tests/test_models.py
index a09447647fddc3965fcf11aa067f931a308a69f7..3e7f9e7ba7b4c3314cd49464448aeba4e7f8783a 100644
--- a/opentech/apply/funds/tests/test_models.py
+++ b/opentech/apply/funds/tests/test_models.py
@@ -489,10 +489,19 @@ class TestSubmissionRenderMethods(TestCase):
 
     def test_named_blocks_dont_break_if_no_response(self):
         submission = ApplicationSubmissionFactory()
+
         # the user didn't respond
         del submission.form_data['value']
+
+        # value doesnt sneak into raw_data
         self.assertTrue('value' not in submission.raw_data)
-        self.assertTrue('duration' in submission.raw_data)
+
+        # value field_id gone
+        field_id = submission.get_definitive_id('value')
+        self.assertTrue(field_id not in submission.raw_data)
+
+        # value attr is None
+        self.assertIsNone(submission.value)
 
     def test_file_private_url_included(self):
         submission = ApplicationSubmissionFactory()
diff --git a/opentech/apply/funds/tests/test_views.py b/opentech/apply/funds/tests/test_views.py
index b5a7b0235a4270049372f36b0ff003a134e214a1..383b6db67063c9743822070713ef9fc2c6f0403d 100644
--- a/opentech/apply/funds/tests/test_views.py
+++ b/opentech/apply/funds/tests/test_views.py
@@ -1,13 +1,16 @@
 from datetime import timedelta
-import json
 
 from django.contrib.auth.models import AnonymousUser
+from django.core.exceptions import PermissionDenied
+from django.http import Http404
+from django.test import RequestFactory, TestCase
 from django.urls import reverse
 from django.utils import timezone
 from django.utils.text import slugify
 
 from opentech.apply.activity.models import Activity, TEAM
 from opentech.apply.projects.models import Project
+from opentech.apply.projects.tests.factories import ProjectFactory
 from opentech.apply.determinations.tests.factories import DeterminationFactory
 from opentech.apply.funds.tests.factories import (
     ApplicationSubmissionFactory,
@@ -22,7 +25,6 @@ from opentech.apply.funds.tests.factories import (
     SealedSubmissionFactory,
 )
 from opentech.apply.review.tests.factories import ReviewFactory
-from opentech.apply.stream_forms.testing.factories import flatten_for_form
 from opentech.apply.users.tests.factories import (
     ReviewerFactory,
     StaffFactory,
@@ -33,6 +35,8 @@ from opentech.apply.utils.testing import make_request
 from opentech.apply.utils.testing.tests import BaseViewTestCase
 
 from ..models import ApplicationRevision, ApplicationSubmission
+from ..views import SubmissionDetailSimplifiedView
+from .factories import CustomFormFieldsFactory
 
 
 def prepare_form_data(submission, **kwargs):
@@ -43,22 +47,7 @@ def prepare_form_data(submission, **kwargs):
         field_id = submission.field(field).id
         data[field_id] = value
 
-    address_field = submission.named_blocks['address']
-    address = data.pop(address_field)
-    data.update(**prepare_address(address, address_field))
-
-    return data
-
-
-def prepare_address(address, field):
-    address = json.loads(address)
-    address['locality'] = {
-        'localityname': address.pop('localityname'),
-        'administrativearea': address.pop('administrativearea'),
-        'postalcode': address.pop('postalcode'),
-    }
-    address = flatten_for_form(address, field, number=True)
-    return address
+    return CustomFormFieldsFactory.form_response(submission.form_fields, data)
 
 
 class BaseSubmissionViewTestCase(BaseViewTestCase):
@@ -453,6 +442,7 @@ class TestRevisionsView(BaseSubmissionViewTestCase):
 
         submission = self.refresh(submission)
 
+        self.maxDiff = None
         self.assertEqual(submission.status, 'proposal_discussion')
         self.assertEqual(submission.revisions.count(), 2)
         self.assertDictEqual(submission.revisions.last().form_data, old_data)
@@ -470,6 +460,7 @@ class TestRevisionsView(BaseSubmissionViewTestCase):
 
         submission = self.refresh(submission)
 
+        self.maxDiff = None
         self.assertEqual(submission.status, 'draft_proposal')
         self.assertEqual(submission.revisions.count(), 2)
         self.assertDictEqual(submission.draft_revision.form_data, submission.from_draft().form_data)
@@ -491,9 +482,9 @@ class TestRevisionsView(BaseSubmissionViewTestCase):
         submission = self.refresh(submission)
 
         self.maxDiff = None
-        self.assertEqual(submission.revisions.count(), 2)
         self.assertDictEqual(submission.draft_revision.form_data, submission.from_draft().form_data)
         self.assertDictEqual(submission.live_revision.form_data, submission.form_data)
+        self.assertEqual(submission.revisions.count(), 2)
 
         self.assertEqual(submission.title, newer_title)
 
@@ -662,6 +653,37 @@ class TestSuperUserSubmissionView(BaseSubmissionViewTestCase):
         self.assertEqual(activity.visibility, TEAM)
 
 
+class TestSubmissionDetailSimplifiedView(TestCase):
+    def test_staff_only(self):
+        factory = RequestFactory()
+        submission = ApplicationSubmissionFactory()
+        ProjectFactory(submission=submission)
+
+        request = factory.get(f'/submission/{submission.pk}')
+        request.user = StaffFactory()
+
+        response = SubmissionDetailSimplifiedView.as_view()(request, pk=submission.pk)
+        self.assertEqual(response.status_code, 200)
+
+        request.user = ApplicantFactory()
+        with self.assertRaises(PermissionDenied):
+            SubmissionDetailSimplifiedView.as_view()(request, pk=submission.pk)
+
+    def test_project_required(self):
+        factory = RequestFactory()
+        submission = ApplicationSubmissionFactory()
+
+        request = factory.get(f'/submission/{submission.pk}')
+        request.user = StaffFactory()
+
+        with self.assertRaises(Http404):
+            SubmissionDetailSimplifiedView.as_view()(request, pk=submission.pk)
+
+        ProjectFactory(submission=submission)
+        response = SubmissionDetailSimplifiedView.as_view()(request, pk=submission.pk)
+        self.assertEqual(response.status_code, 200)
+
+
 class BaseSubmissionFileViewTestCase(BaseViewTestCase):
     url_name = 'funds:submissions:{}'
     base_view_name = 'serve_private_media'
diff --git a/opentech/apply/funds/urls.py b/opentech/apply/funds/urls.py
index 4f035de13e61112260e7853140ee7eeaf848a5f3..706fddede0349dcf01515894b85a659f4e23e46e 100644
--- a/opentech/apply/funds/urls.py
+++ b/opentech/apply/funds/urls.py
@@ -15,6 +15,7 @@ from .views import (
     SubmissionSealedView,
     SubmissionDeleteView,
     SubmissionPrivateMediaView,
+    SubmissionDetailSimplifiedView
 )
 from .api_views import (
     CommentEdit,
@@ -43,6 +44,7 @@ submission_urls = ([
         path('', SubmissionDetailView.as_view(), name="detail"),
         path('edit/', SubmissionEditView.as_view(), name="edit"),
         path('sealed/', SubmissionSealedView.as_view(), name="sealed"),
+        path('simplified/', SubmissionDetailSimplifiedView.as_view(), name="simplified"),
         path('delete/', SubmissionDeleteView.as_view(), name="delete"),
         path(
             'documents/<uuid:field_id>/<str:file_name>',
diff --git a/opentech/apply/funds/views.py b/opentech/apply/funds/views.py
index d118e93365c94c00f457d729b32e1f0f03fe003f..af01e3a39e0e0ad6b51f1d5d86778c65865d9e6c 100644
--- a/opentech/apply/funds/views.py
+++ b/opentech/apply/funds/views.py
@@ -53,6 +53,7 @@ from .models import (
     RoundBase,
     LabBase
 )
+from .paginators import LazyPaginator
 from .permissions import is_user_has_access_to_view_submission
 from .tables import (
     AdminSubmissionsTable,
@@ -70,6 +71,7 @@ class BaseAdminSubmissionsTable(SingleTableMixin, FilterView):
     table_class = AdminSubmissionsTable
     filterset_class = SubmissionFilterAndSearch
     filter_action = ''
+    table_pagination = {'klass': LazyPaginator}
 
     excluded_fields = []
 
@@ -256,6 +258,7 @@ class SubmissionOverviewView(AllActivityContextMixin, BaseAdminSubmissionsTable)
             status: {
                 'name': data['name'],
                 'count': sum(status_counts.get(status, 0) for status in data['statuses']),
+                'url': reverse_lazy("funds:submissions:status", kwargs={'status': status})
             }
             for status, data in PHASES_MAPPING.items()
         }
@@ -931,3 +934,17 @@ class SubmissionPrivateMediaView(UserPassesTestMixin, PrivateMediaView):
 
     def test_func(self):
         return is_user_has_access_to_view_submission(self.request.user, self.submission)
+
+
+@method_decorator(staff_required, name='dispatch')
+class SubmissionDetailSimplifiedView(DetailView):
+    model = ApplicationSubmission
+    template_name_suffix = '_simplified_detail'
+
+    def get_object(self, queryset=None):
+        obj = super().get_object(queryset)
+
+        if not hasattr(obj, 'project'):
+            raise Http404
+
+        return obj
diff --git a/opentech/apply/projects/admin.py b/opentech/apply/projects/admin.py
index 711563188a21bdd91c2e29ea110956626a556f9e..eac072de393a4d4ba1efd818674951985d011bb1 100644
--- a/opentech/apply/projects/admin.py
+++ b/opentech/apply/projects/admin.py
@@ -1,6 +1,7 @@
+from django.utils.html import mark_safe
 from wagtail.contrib.modeladmin.options import ModelAdmin, ModelAdminGroup
 
-from .models import DocumentCategory
+from .models import DocumentCategory, ProjectApprovalForm
 
 
 class DocumentCategoryAdmin(ModelAdmin):
@@ -9,9 +10,24 @@ class DocumentCategoryAdmin(ModelAdmin):
     list_display = ('name', 'recommended_minimum',)
 
 
+class ProjectApprovalFormAdmin(ModelAdmin):
+    model = ProjectApprovalForm
+    menu_icon = 'form'
+    list_display = ('name', 'used_by',)
+
+    def used_by(self, obj):
+        rows = list()
+        for field in ('funds', 'labs',):
+            related = ', '.join(getattr(obj, f'{field}').values_list('title', flat=True))
+            if related:
+                rows.append(related)
+        return mark_safe('<br>'.join(rows))
+
+
 class ManageAdminGoup(ModelAdminGroup):
     menu_label = 'Manage'
     menu_icon = 'folder-open-inverse'
     items = (
         DocumentCategoryAdmin,
+        ProjectApprovalFormAdmin,
     )
diff --git a/opentech/apply/projects/files.py b/opentech/apply/projects/files.py
new file mode 100644
index 0000000000000000000000000000000000000000..2195d989e212cfc57b3eb0d79a03a7966558a443
--- /dev/null
+++ b/opentech/apply/projects/files.py
@@ -0,0 +1,28 @@
+def flatten(iterable):
+    """Flatten the given iterable into an iterable of non-list items"""
+    for item in iterable:
+        if isinstance(item, list):
+            yield from flatten(item)
+        else:
+            yield item
+
+
+def get_files(project):
+    """
+    Get files from the given Project's Submission.
+
+    A Submission can have fields which contain multiple files, these are
+    returned from Submission.data as a list meaning the given fields can be in
+    the form of:
+
+        [obj1, [obj2, obj3]]
+
+    This function will flatten this providing a single level iterable:
+
+        [obj1, obj2, obj3]
+
+    """
+    file_field_names = project.submission.file_field_ids
+    file_fields = (project.submission.data(field) for field in file_field_names)
+
+    return list(flatten(file_fields))
diff --git a/opentech/apply/projects/filters.py b/opentech/apply/projects/filters.py
new file mode 100644
index 0000000000000000000000000000000000000000..260cc0ec0cc8909f4af22a1f6db53b8ad4556cb2
--- /dev/null
+++ b/opentech/apply/projects/filters.py
@@ -0,0 +1,43 @@
+import django_filters as filters
+from django import forms
+from django.contrib.auth import get_user_model
+
+from opentech.apply.funds.tables import (
+    Select2ModelMultipleChoiceFilter,
+    Select2MultipleChoiceFilter,
+    get_used_funds
+)
+
+from .models import (
+    PROJECT_STATUS_CHOICES,
+    REQUEST_STATUS_CHOICES,
+    PaymentRequest,
+    Project
+)
+
+User = get_user_model()
+
+
+def get_project_leads(request):
+    return User.objects.filter(lead_projects__isnull=False).distinct()
+
+
+class PaymentRequestListFilter(filters.FilterSet):
+    fund = Select2ModelMultipleChoiceFilter(label='Funds', queryset=get_used_funds, field_name='project__submission__page')
+    status = Select2MultipleChoiceFilter(label='Status', choices=REQUEST_STATUS_CHOICES)
+    lead = Select2ModelMultipleChoiceFilter(label='Lead', queryset=get_project_leads, field_name='project__lead')
+
+    class Meta:
+        fields = ['lead', 'fund', 'status']
+        model = PaymentRequest
+
+
+class ProjectListFilter(filters.FilterSet):
+    fund = Select2ModelMultipleChoiceFilter(label='Funds', queryset=get_used_funds)
+    lead = Select2ModelMultipleChoiceFilter(label='Lead', queryset=get_project_leads)
+    status = Select2MultipleChoiceFilter(label='Status', choices=PROJECT_STATUS_CHOICES)
+    query = filters.CharFilter(field_name='title', lookup_expr="icontains", widget=forms.HiddenInput)
+
+    class Meta:
+        fields = ['status', 'lead', 'fund']
+        model = Project
diff --git a/opentech/apply/projects/forms.py b/opentech/apply/projects/forms.py
index 13d19826fdf490fba6a6f3b2ca7b60e92158837f..5dc566f88d8a51ea809e0e9e25289dc10699f2b1 100644
--- a/opentech/apply/projects/forms.py
+++ b/opentech/apply/projects/forms.py
@@ -1,11 +1,102 @@
+import functools
+
 from django import forms
+from django.contrib.auth import get_user_model
+from django.core.files.base import ContentFile
+from django.db import transaction
 from django.db.models import Q
 
 from addressfield.fields import AddressField
 from opentech.apply.funds.models import ApplicationSubmission
+from opentech.apply.stream_forms.fields import MultiFileField
 from opentech.apply.users.groups import STAFF_GROUP_NAME
 
-from .models import COMMITTED, Approval, PacketFile, Project
+from .models import (
+    CHANGES_REQUESTED,
+    COMMITTED,
+    DECLINED,
+    PAID,
+    REQUEST_STATUS_CHOICES,
+    SUBMITTED,
+    UNDER_REVIEW,
+    Approval,
+    Contract,
+    PacketFile,
+    PaymentReceipt,
+    PaymentRequest,
+    Project
+)
+
+
+User = get_user_model()
+
+
+def filter_choices(available, choices):
+    return [(k, v) for k, v in available if k in choices]
+
+
+filter_request_choices = functools.partial(filter_choices, REQUEST_STATUS_CHOICES)
+
+
+class ApproveContractForm(forms.Form):
+    id = forms.IntegerField(widget=forms.HiddenInput())
+
+    def __init__(self, instance, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.instance = instance
+        if instance:
+            self.fields['id'].initial = instance.id
+
+    def clean_id(self):
+        if self.has_changed():
+            raise forms.ValidationError('Something changed before your approval please re-review')
+
+    def clean(self):
+        if not self.instance:
+            raise forms.ValidationError('The contract you were trying to approve has already been approved')
+
+        if not self.instance.is_signed:
+            raise forms.ValidationError('You can only approve a signed contract')
+
+        super().clean()
+
+    def save(self, *args, **kwargs):
+        self.instance.save()
+        return self.instance
+
+
+class ChangePaymentRequestStatusForm(forms.ModelForm):
+    name_prefix = 'change_payment_request_status_form'
+
+    class Meta:
+        fields = ['status', 'comment', 'paid_value']
+        model = PaymentRequest
+
+    def __init__(self, instance, *args, **kwargs):
+        super().__init__(instance=instance, *args, **kwargs)
+
+        self.initial['paid_value'] = self.instance.requested_value
+
+        status_field = self.fields['status']
+
+        possible_status_transitions_lut = {
+            CHANGES_REQUESTED: filter_request_choices([DECLINED]),
+            SUBMITTED: filter_request_choices([CHANGES_REQUESTED, UNDER_REVIEW, DECLINED]),
+            UNDER_REVIEW: filter_request_choices([PAID]),
+        }
+        status_field.choices = possible_status_transitions_lut.get(instance.status, [])
+
+        if instance.status != UNDER_REVIEW:
+            del self.fields['paid_value']
+
+    def clean(self):
+        cleaned_data = super().clean()
+        status = cleaned_data['status']
+        paid_value = cleaned_data.get('paid_value')
+
+        if paid_value and status != PAID:
+            self.add_error('paid_value', 'You can only set a value when moving to the Paid status.')
+        return cleaned_data
 
 
 class CreateProjectForm(forms.Form):
@@ -26,15 +117,24 @@ class CreateProjectForm(forms.Form):
 
 
 class CreateApprovalForm(forms.ModelForm):
+    by = forms.ModelChoiceField(
+        queryset=User.objects.approvers(),
+        widget=forms.HiddenInput(),
+    )
+
     class Meta:
         model = Approval
-        fields = ['by']
-        widgets = {'by': forms.HiddenInput()}
+        fields = ('by',)
 
     def __init__(self, user=None, *args, **kwargs):
-        initial = kwargs.pop('initial', {})
-        initial.update(by=user)
-        super().__init__(*args, initial=initial, **kwargs)
+        self.user = user
+        super().__init__(*args, **kwargs)
+
+    def clean_by(self):
+        by = self.cleaned_data['by']
+        if by != self.user:
+            raise forms.ValidationError('Cannot approve for a different user')
+        return by
 
 
 class ProjectEditForm(forms.ModelForm):
@@ -63,7 +163,20 @@ class ProjectEditForm(forms.ModelForm):
 
 
 class ProjectApprovalForm(ProjectEditForm):
+    def __init__(self, *args, extra_fields=None, **kwargs):
+        super().__init__(*args, **kwargs)
+        if extra_fields:
+            self.fields = {
+                **self.fields,
+                **extra_fields,
+            }
+
     def save(self, *args, **kwargs):
+        self.instance.form_data = {
+            field: self.cleaned_data[field]
+            for field in self.instance.question_field_ids
+            if field in self.cleaned_data
+        }
         self.instance.user_has_updated_details = True
         return super().save(*args, **kwargs)
 
@@ -86,6 +199,113 @@ class RemoveDocumentForm(forms.ModelForm):
         super().__init__(*args, **kwargs)
 
 
+class PaymentRequestBaseForm(forms.ModelForm):
+    class Meta:
+        fields = ['requested_value', 'invoice', 'date_from', 'date_to']
+        model = PaymentRequest
+        widgets = {
+            'date_from': forms.DateInput,
+            'date_to': forms.DateInput,
+        }
+        labels = {
+            'requested_value': 'Requested Value ($)'
+        }
+
+    def __init__(self, user=None, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.fields['requested_value'].widget.attrs['min'] = 0
+
+    def clean(self):
+        cleaned_data = super().clean()
+        date_from = cleaned_data['date_from']
+        date_to = cleaned_data['date_to']
+
+        if date_from > date_to:
+            self.add_error('date_from', 'Date From must be before Date To')
+
+        return cleaned_data
+
+
+class CreatePaymentRequestForm(PaymentRequestBaseForm):
+    receipts = MultiFileField()
+
+    def save(self, commit=True):
+        request = super().save(commit=commit)
+
+        PaymentReceipt.objects.bulk_create(
+            PaymentReceipt(payment_request=request, file=receipt)
+            for receipt in self.cleaned_data['receipts']
+        )
+
+        return request
+
+
+class EditPaymentRequestForm(PaymentRequestBaseForm):
+    receipt_list = forms.ModelMultipleChoiceField(
+        widget=forms.CheckboxSelectMultiple(attrs={'class': 'delete'}),
+        queryset=PaymentReceipt.objects.all(),
+        required=False,
+        label='Receipts'
+    )
+    receipts = MultiFileField(label='', required=False)
+
+    def __init__(self, user=None, instance=None, *args, **kwargs):
+        super().__init__(*args, instance=instance, **kwargs)
+
+        self.fields['receipt_list'].queryset = instance.receipts.all()
+
+        self.fields['requested_value'].label = 'Value'
+
+    @transaction.atomic
+    def save(self, commit=True):
+        request = super().save(commit=commit)
+
+        removed_receipts = self.cleaned_data['receipt_list']
+
+        removed_receipts.delete()
+
+        to_add = self.cleaned_data['receipts']
+        if to_add:
+            PaymentReceipt.objects.bulk_create(
+                PaymentReceipt(payment_request=request, file=receipt)
+                for receipt in to_add
+            )
+        return request
+
+
+class SelectDocumentForm(forms.ModelForm):
+    document = forms.ChoiceField(
+        label="Document",
+        widget=forms.Select(attrs={'id': 'from_submission'})
+    )
+
+    class Meta:
+        model = PacketFile
+        fields = ['category', 'document']
+
+    def __init__(self, existing_files, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        self.files = existing_files
+
+        choices = [(f.url, f.filename) for f in self.files]
+
+        self.fields['document'].choices = choices
+
+    def clean_document(self):
+        file_url = self.cleaned_data['document']
+        for file in self.files:
+            if file.url == file_url:
+                new_file = ContentFile(file.read())
+                new_file.name = file.filename
+                return new_file
+        raise forms.ValidationError("File not found on submission")
+
+    @transaction.atomic()
+    def save(self, *args, **kwargs):
+        return super().save(*args, **kwargs)
+
+
 class SetPendingForm(forms.ModelForm):
     class Meta:
         fields = ['id']
@@ -109,11 +329,26 @@ class SetPendingForm(forms.ModelForm):
         return super().save(*args, **kwargs)
 
 
+class UploadContractForm(forms.ModelForm):
+    class Meta:
+        fields = ['file']
+        model = Contract
+
+
+class StaffUploadContractForm(forms.ModelForm):
+    class Meta:
+        fields = ['file', 'is_signed']
+        model = Contract
+
+
 class UploadDocumentForm(forms.ModelForm):
     class Meta:
         fields = ['title', 'category', 'document']
         model = PacketFile
         widgets = {'title': forms.TextInput()}
+        labels = {
+            "title": "File Name",
+        }
 
     def __init__(self, user=None, instance=None, *args, **kwargs):
         super().__init__(*args, **kwargs)
diff --git a/opentech/apply/projects/migrations/0013_add_contract.py b/opentech/apply/projects/migrations/0013_add_contract.py
new file mode 100644
index 0000000000000000000000000000000000000000..4b92228e45f0e0a9321999bd593cde89521dcb68
--- /dev/null
+++ b/opentech/apply/projects/migrations/0013_add_contract.py
@@ -0,0 +1,28 @@
+# Generated by Django 2.0.13 on 2019-08-12 15:21
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import opentech.apply.projects.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('application_projects', '0012_adjust_storage_class'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Contract',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('file', models.FileField(upload_to=opentech.apply.projects.models.contract_path)),
+                ('is_signed', models.BooleanField('Signed?', default=False)),
+                ('created_at', models.DateTimeField(auto_now_add=True)),
+                ('approver', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contracts', to=settings.AUTH_USER_MODEL)),
+                ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contracts', to='application_projects.Project')),
+            ],
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0014_add_payment_related_models.py b/opentech/apply/projects/migrations/0014_add_payment_related_models.py
new file mode 100644
index 0000000000000000000000000000000000000000..022f6504499d728a204f096babd084921bbe69b3
--- /dev/null
+++ b/opentech/apply/projects/migrations/0014_add_payment_related_models.py
@@ -0,0 +1,59 @@
+# Generated by Django 2.0.13 on 2019-08-14 14:09
+
+from decimal import Decimal
+from django.conf import settings
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+import opentech.apply.projects.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('application_projects', '0013_add_contract'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='PaymentApproval',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('created_at', models.DateTimeField(auto_now_add=True)),
+                ('by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payment_approvals', to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='PaymentReceipt',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('file', models.FileField(storage=django.core.files.storage.FileSystemStorage(), upload_to=opentech.apply.projects.models.receipt_path)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='PaymentRequest',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('invoice', models.FileField(storage=django.core.files.storage.FileSystemStorage(), upload_to=opentech.apply.projects.models.invoice_path)),
+                ('value', models.DecimalField(decimal_places=2, default=0, max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))])),
+                ('requested_at', models.DateTimeField(auto_now_add=True)),
+                ('date_from', models.DateTimeField()),
+                ('date_to', models.DateTimeField()),
+                ('comment', models.TextField()),
+                ('status', models.TextField(choices=[('submitted', 'Submitted'), ('under_review', 'Under Review'), ('paid', 'Paid'), ('declined', 'Declined')], default='submitted')),
+                ('by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payment_requests', to=settings.AUTH_USER_MODEL)),
+                ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payment_requests', to='application_projects.Project')),
+            ],
+        ),
+        migrations.AddField(
+            model_name='paymentreceipt',
+            name='payment_request',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='receipts', to='application_projects.PaymentRequest'),
+        ),
+        migrations.AddField(
+            model_name='paymentapproval',
+            name='request',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='approvals', to='application_projects.PaymentRequest'),
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0015_add_payment_request_changes_requested.py b/opentech/apply/projects/migrations/0015_add_payment_request_changes_requested.py
new file mode 100644
index 0000000000000000000000000000000000000000..3d11bdabdbe040c017477e5c7663d203d7121a0b
--- /dev/null
+++ b/opentech/apply/projects/migrations/0015_add_payment_request_changes_requested.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.13 on 2019-08-22 08:01
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0014_add_payment_related_models'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='paymentrequest',
+            name='status',
+            field=models.TextField(choices=[('submitted', 'Submitted'), ('changes_requested', 'Changes Requested'), ('under_review', 'Under Review'), ('paid', 'Paid'), ('declined', 'Declined')], default='submitted'),
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0016_add_project_settings.py b/opentech/apply/projects/migrations/0016_add_project_settings.py
new file mode 100644
index 0000000000000000000000000000000000000000..fcd5b8ae0ec1429ba00c827c432a079e768053e8
--- /dev/null
+++ b/opentech/apply/projects/migrations/0016_add_project_settings.py
@@ -0,0 +1,26 @@
+# Generated by Django 2.0.13 on 2019-08-28 08:59
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('wagtailcore', '0040_page_draft_title'),
+        ('application_projects', '0015_add_payment_request_changes_requested'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ProjectSettings',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('compliance_email', models.TextField(verbose_name='Compliance Email')),
+                ('site', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, to='wagtailcore.Site')),
+            ],
+            options={
+                'abstract': False,
+            },
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0017_add_sent_to_compliance_at.py b/opentech/apply/projects/migrations/0017_add_sent_to_compliance_at.py
new file mode 100644
index 0000000000000000000000000000000000000000..157528c41484389ea61408fc77bbda4a7697f1ae
--- /dev/null
+++ b/opentech/apply/projects/migrations/0017_add_sent_to_compliance_at.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.13 on 2019-08-20 13:13
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0016_add_project_settings'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='project',
+            name='sent_to_compliance_at',
+            field=models.DateTimeField(null=True),
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0018_projectapprovalform.py b/opentech/apply/projects/migrations/0018_projectapprovalform.py
new file mode 100644
index 0000000000000000000000000000000000000000..6a2f11648bb5dc70e8e5ecc33fddd4336751f7e9
--- /dev/null
+++ b/opentech/apply/projects/migrations/0018_projectapprovalform.py
@@ -0,0 +1,24 @@
+# Generated by Django 2.0.13 on 2019-08-11 06:01
+
+from django.db import migrations, models
+import opentech.apply.stream_forms.blocks
+import wagtail.core.blocks
+import wagtail.core.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0017_add_sent_to_compliance_at'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ProjectApprovalForm',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=255)),
+                ('form_fields', wagtail.core.fields.StreamField([('text_markup', wagtail.core.blocks.RichTextBlock(group='Custom', label='Section text/header')), ('char', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('format', wagtail.core.blocks.ChoiceBlock(choices=[('email', 'Email'), ('url', 'URL')], label='Format', required=False)), ('default_value', wagtail.core.blocks.CharBlock(label='Default value', required=False))], group='Fields')), ('text', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.TextBlock(label='Default value', required=False))], group='Fields')), ('number', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.CharBlock(label='Default value', required=False))], group='Fields')), ('checkbox', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.BooleanBlock(required=False))], group='Fields')), ('radios', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice')))], group='Fields')), ('dropdown', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice')))], group='Fields')), ('checkboxes', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('checkboxes', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Checkbox')))], group='Fields')), ('date', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.DateBlock(required=False))], group='Fields')), ('time', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.TimeBlock(required=False))], group='Fields')), ('datetime', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.DateTimeBlock(required=False))], group='Fields')), ('image', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False))], group='Fields')), ('file', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False))], group='Fields')), ('multi_file', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False))], group='Fields')), ('group_toggle', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(default=True, label='Required')), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice'), help_text='Please create only two choices for toggle. First choice will revel the group and the second hide it. Additional choices will be ignored.'))], group='Custom')), ('group_toggle_end', opentech.apply.stream_forms.blocks.GroupToggleEndBlock(group='Custom'))])),
+            ],
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0019_add_form_to_projects.py b/opentech/apply/projects/migrations/0019_add_form_to_projects.py
new file mode 100644
index 0000000000000000000000000000000000000000..77a352e018a704a981947717906b8071398fe7a1
--- /dev/null
+++ b/opentech/apply/projects/migrations/0019_add_form_to_projects.py
@@ -0,0 +1,28 @@
+# Generated by Django 2.0.13 on 2019-08-12 00:55
+
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations
+import opentech.apply.stream_forms.blocks
+import opentech.apply.stream_forms.files
+import wagtail.core.blocks
+import wagtail.core.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0018_projectapprovalform'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='project',
+            name='form_data',
+            field=django.contrib.postgres.fields.jsonb.JSONField(default=dict, encoder=opentech.apply.stream_forms.files.StreamFieldDataEncoder),
+        ),
+        migrations.AddField(
+            model_name='project',
+            name='form_fields',
+            field=wagtail.core.fields.StreamField([('text_markup', wagtail.core.blocks.RichTextBlock(group='Custom', label='Section text/header')), ('char', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('format', wagtail.core.blocks.ChoiceBlock(choices=[('email', 'Email'), ('url', 'URL')], label='Format', required=False)), ('default_value', wagtail.core.blocks.CharBlock(label='Default value', required=False))], group='Fields')), ('text', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.TextBlock(label='Default value', required=False))], group='Fields')), ('number', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.CharBlock(label='Default value', required=False))], group='Fields')), ('checkbox', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.BooleanBlock(required=False))], group='Fields')), ('radios', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice')))], group='Fields')), ('dropdown', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice')))], group='Fields')), ('checkboxes', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('checkboxes', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Checkbox')))], group='Fields')), ('date', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.DateBlock(required=False))], group='Fields')), ('time', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.TimeBlock(required=False))], group='Fields')), ('datetime', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.DateTimeBlock(required=False))], group='Fields')), ('image', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False))], group='Fields')), ('file', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False))], group='Fields')), ('multi_file', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False))], group='Fields')), ('group_toggle', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(default=True, label='Required')), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice'), help_text='Please create only two choices for toggle. First choice will revel the group and the second hide it. Additional choices will be ignored.'))], group='Custom')), ('group_toggle_end', opentech.apply.stream_forms.blocks.GroupToggleEndBlock(group='Custom'))], null=True),
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0020_rename_value_to_requested_value.py b/opentech/apply/projects/migrations/0020_rename_value_to_requested_value.py
new file mode 100644
index 0000000000000000000000000000000000000000..d3006c6ad30c780785475428b3c7d64c4f1b86ec
--- /dev/null
+++ b/opentech/apply/projects/migrations/0020_rename_value_to_requested_value.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.13 on 2019-08-26 11:54
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0019_add_form_to_projects'),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name='paymentrequest',
+            old_name='value',
+            new_name='requested_value',
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0021_add_paid_value.py b/opentech/apply/projects/migrations/0021_add_paid_value.py
new file mode 100644
index 0000000000000000000000000000000000000000..a24f1cb74f29d7f8838b793bea2d9fcc02474b49
--- /dev/null
+++ b/opentech/apply/projects/migrations/0021_add_paid_value.py
@@ -0,0 +1,20 @@
+# Generated by Django 2.0.13 on 2019-08-26 12:04
+
+from decimal import Decimal
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0020_rename_value_to_requested_value'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='paymentrequest',
+            name='paid_value',
+            field=models.DecimalField(decimal_places=2, max_digits=10, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))]),
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0022_update_field_definitions_for_forms.py b/opentech/apply/projects/migrations/0022_update_field_definitions_for_forms.py
new file mode 100644
index 0000000000000000000000000000000000000000..be7ca59859a06812a23d3f250267d7d8d629bd69
--- /dev/null
+++ b/opentech/apply/projects/migrations/0022_update_field_definitions_for_forms.py
@@ -0,0 +1,26 @@
+# Generated by Django 2.1.11 on 2019-09-05 08:44
+
+from django.db import migrations
+import opentech.apply.stream_forms.blocks
+import wagtail.core.blocks
+import wagtail.core.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0021_add_paid_value'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='project',
+            name='form_fields',
+            field=wagtail.core.fields.StreamField([('text_markup', wagtail.core.blocks.RichTextBlock(group='Custom', label='Section text/header')), ('char', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('format', wagtail.core.blocks.ChoiceBlock(choices=[('email', 'Email'), ('url', 'URL')], label='Format', required=False)), ('default_value', wagtail.core.blocks.CharBlock(label='Default value', required=False))], group='Fields')), ('text', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.TextBlock(label='Default value', required=False))], group='Fields')), ('number', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.CharBlock(label='Default value', required=False))], group='Fields')), ('checkbox', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.BooleanBlock(required=False))], group='Fields')), ('radios', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice')))], group='Fields')), ('dropdown', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice')))], group='Fields')), ('checkboxes', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('checkboxes', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Checkbox')))], group='Fields')), ('date', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.DateBlock(required=False))], group='Fields')), ('time', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.TimeBlock(required=False))], group='Fields')), ('datetime', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.DateTimeBlock(required=False))], group='Fields')), ('image', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False))], group='Fields')), ('file', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False))], group='Fields')), ('multi_file', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False))], group='Fields')), ('group_toggle', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(default=True, label='Required')), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice'), help_text='Please create only two choices for toggle. First choice will revel the group and the second hide it. Additional choices will be ignored.'))], group='Custom')), ('group_toggle_end', opentech.apply.stream_forms.blocks.GroupToggleEndBlock(group='Custom'))], null=True),
+        ),
+        migrations.AlterField(
+            model_name='projectapprovalform',
+            name='form_fields',
+            field=wagtail.core.fields.StreamField([('text_markup', wagtail.core.blocks.RichTextBlock(group='Custom', label='Section text/header')), ('char', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('format', wagtail.core.blocks.ChoiceBlock(choices=[('email', 'Email'), ('url', 'URL')], label='Format', required=False)), ('default_value', wagtail.core.blocks.CharBlock(label='Default value', required=False))], group='Fields')), ('text', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.TextBlock(label='Default value', required=False))], group='Fields')), ('number', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.CharBlock(label='Default value', required=False))], group='Fields')), ('checkbox', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.BooleanBlock(required=False))], group='Fields')), ('radios', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice')))], group='Fields')), ('dropdown', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice')))], group='Fields')), ('checkboxes', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('checkboxes', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Checkbox')))], group='Fields')), ('date', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.DateBlock(required=False))], group='Fields')), ('time', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.TimeBlock(required=False))], group='Fields')), ('datetime', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.DateTimeBlock(required=False))], group='Fields')), ('image', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False))], group='Fields')), ('file', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False))], group='Fields')), ('multi_file', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False))], group='Fields')), ('group_toggle', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(default=True, label='Required')), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice'), help_text='Please create only two choices for toggle. First choice will revel the group and the second hide it. Additional choices will be ignored.'))], group='Custom')), ('group_toggle_end', opentech.apply.stream_forms.blocks.GroupToggleEndBlock(group='Custom'))]),
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0023_ensure_contracts_uses_private_storage.py b/opentech/apply/projects/migrations/0023_ensure_contracts_uses_private_storage.py
new file mode 100644
index 0000000000000000000000000000000000000000..b7cbcf235b2f53d99e5a93a7ab31c7c46ef8ce44
--- /dev/null
+++ b/opentech/apply/projects/migrations/0023_ensure_contracts_uses_private_storage.py
@@ -0,0 +1,20 @@
+# Generated by Django 2.1.11 on 2019-09-05 08:48
+
+import django.core.files.storage
+from django.db import migrations, models
+import opentech.apply.projects.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0022_update_field_definitions_for_forms'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='contract',
+            name='file',
+            field=models.FileField(storage=django.core.files.storage.FileSystemStorage(), upload_to=opentech.apply.projects.models.contract_path),
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0024_allow_no_comments_on_pr.py b/opentech/apply/projects/migrations/0024_allow_no_comments_on_pr.py
new file mode 100644
index 0000000000000000000000000000000000000000..a694b4d9607c65c74d41dce65b1d7fe60f8288bd
--- /dev/null
+++ b/opentech/apply/projects/migrations/0024_allow_no_comments_on_pr.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.1.11 on 2019-09-05 08:49
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0023_ensure_contracts_uses_private_storage'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='paymentrequest',
+            name='comment',
+            field=models.TextField(blank=True),
+        ),
+    ]
diff --git a/opentech/apply/projects/models.py b/opentech/apply/projects/models.py
index 4b3f1b98f6011ec827ed8e4632f7495a43134df7..7e2739bd15dbb0f3ac8783c77f3f7780c7278cab 100644
--- a/opentech/apply/projects/models.py
+++ b/opentech/apply/projects/models.py
@@ -2,22 +2,57 @@ import collections
 import decimal
 import json
 import logging
+import os
+
 
-from addressfield.fields import ADDRESS_FIELDS_ORDER
 from django.conf import settings
 from django.contrib.contenttypes.fields import GenericRelation
+from django.contrib.postgres.fields import JSONField
 from django.core.exceptions import ValidationError
 from django.core.validators import MinValueValidator
 from django.db import models
+from django.db.models import F, Max, Sum, Value as V
+from django.db.models.functions import Coalesce
+from django.db.models.signals import post_delete
+from django.dispatch.dispatcher import receiver
 from django.urls import reverse
+from django.utils import timezone
 from django.utils.translation import ugettext as _
+from wagtail.contrib.settings.models import BaseSetting, register_setting
+from wagtail.admin.edit_handlers import (
+    FieldPanel,
+    StreamFieldPanel,
+)
+from wagtail.core.fields import StreamField
+
+from opentech.apply.funds.models.mixins import AccessFormData
+from opentech.apply.stream_forms.blocks import FormFieldsBlock
+from opentech.apply.stream_forms.files import StreamFieldDataEncoder
+from opentech.apply.stream_forms.models import BaseStreamForm
 
+from addressfield.fields import ADDRESS_FIELDS_ORDER
+from opentech.apply.activity.messaging import MESSAGES, messenger
 from opentech.apply.utils.storage import PrivateStorage
 
-
 logger = logging.getLogger(__name__)
 
 
+def contract_path(instance, filename):
+    return f'projects/{instance.project_id}/contracts/{filename}'
+
+
+def document_path(instance, filename):
+    return f'projects/{instance.project_id}/supporting_documents/{filename}'
+
+
+def invoice_path(instance, filename):
+    return f'projects/{instance.project_id}/payment_invoices/{filename}'
+
+
+def receipt_path(instance, filename):
+    return f'projects/{instance.payment_request.project_id}/payment_receipts/{filename}'
+
+
 class Approval(models.Model):
     project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="approvals")
     by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="approvals")
@@ -31,8 +66,31 @@ class Approval(models.Model):
         return f'Approval of "{self.project.title}" by {self.by}'
 
 
-def document_path(instance, filename):
-    return f'projects/{instance.project_id}/supporting_documents/{filename}'
+class ContractQuerySet(models.QuerySet):
+    def approved(self):
+        return self.filter(is_signed=True, approver__isnull=False)
+
+
+class Contract(models.Model):
+    approver = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name='contracts')
+    project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="contracts")
+
+    file = models.FileField(upload_to=contract_path, storage=PrivateStorage())
+
+    is_signed = models.BooleanField("Signed?", default=False)
+    created_at = models.DateTimeField(auto_now_add=True)
+
+    objects = ContractQuerySet.as_manager()
+
+    @property
+    def state(self):
+        return 'Signed' if self.is_signed else 'Unsigned'
+
+    def __str__(self):
+        return f'Contract for {self.project} ({self.state})'
+
+    def get_absolute_url(self):
+        return reverse('apply:projects:contract', args=[self.project.pk, self.pk])
 
 
 class PacketFile(models.Model):
@@ -53,23 +111,194 @@ class PacketFile(models.Model):
         instance of PacketFile in the supporting documents template.  The
         standard Delegated View flow makes it difficult to create these forms
         in the view or template.
-        """
+       """
         from .forms import RemoveDocumentForm
         return RemoveDocumentForm(instance=self)
 
 
+@receiver(post_delete, sender=PacketFile)
+def delete_packetfile_file(sender, instance, **kwargs):
+    # Remove the file and don't save the base model
+    instance.document.delete(False)
+
+
+class PaymentApproval(models.Model):
+    request = models.ForeignKey('PaymentRequest', on_delete=models.CASCADE, related_name="approvals")
+    by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="payment_approvals")
+
+    created_at = models.DateTimeField(auto_now_add=True)
+
+    def __str__(self):
+        return f'Approval for {self.request} by {self.by}'
+
+
+class PaymentReceipt(models.Model):
+    payment_request = models.ForeignKey("PaymentRequest", on_delete=models.CASCADE, related_name="receipts")
+
+    file = models.FileField(upload_to=receipt_path, storage=PrivateStorage())
+
+    def __str__(self):
+        return os.path.basename(self.file.name)
+
+
+SUBMITTED = 'submitted'
+CHANGES_REQUESTED = 'changes_requested'
+UNDER_REVIEW = 'under_review'
+PAID = 'paid'
+DECLINED = 'declined'
+REQUEST_STATUS_CHOICES = [
+    (SUBMITTED, 'Submitted'),
+    (CHANGES_REQUESTED, 'Changes Requested'),
+    (UNDER_REVIEW, 'Under Review'),
+    (PAID, 'Paid'),
+    (DECLINED, 'Declined'),
+]
+
+
+class PaymentRequestQueryset(models.QuerySet):
+    def in_progress(self):
+        return self.exclude(status__in=[DECLINED, PAID])
+
+    def rejected(self):
+        return self.filter(status=DECLINED)
+
+    def not_rejected(self):
+        return self.exclude(status=DECLINED)
+
+    def total_value(self, field):
+        return self.aggregate(total=Coalesce(Sum(field), V(0)))['total']
+
+    def paid_value(self):
+        return self.filter(status=PAID).total_value('paid_value')
+
+    def unpaid_value(self):
+        return self.filter(status__in=[SUBMITTED, UNDER_REVIEW]).total_value('requested_value')
+
+
+class PaymentRequest(models.Model):
+    project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="payment_requests")
+    by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="payment_requests")
+
+    requested_value = models.DecimalField(
+        default=0,
+        max_digits=10,
+        decimal_places=2,
+        validators=[MinValueValidator(decimal.Decimal('0.01'))],
+    )
+    paid_value = models.DecimalField(
+        max_digits=10,
+        decimal_places=2,
+        validators=[MinValueValidator(decimal.Decimal('0.01'))],
+        null=True
+    )
+
+    invoice = models.FileField(upload_to=invoice_path, storage=PrivateStorage())
+    requested_at = models.DateTimeField(auto_now_add=True)
+    date_from = models.DateTimeField()
+    date_to = models.DateTimeField()
+    comment = models.TextField(blank=True)
+    status = models.TextField(choices=REQUEST_STATUS_CHOICES, default=SUBMITTED)
+
+    objects = PaymentRequestQueryset.as_manager()
+
+    def __str__(self):
+        return f'Payment requested for {self.project}'
+
+    @property
+    def has_changes_requested(self):
+        return self.status == CHANGES_REQUESTED
+
+    @property
+    def status_display(self):
+        return self.get_status_display()
+
+    def can_user_delete(self, user):
+        if user.is_applicant:
+            if self.status in (SUBMITTED, CHANGES_REQUESTED):
+                return True
+
+        if user.is_apply_staff:
+            if self.status in {SUBMITTED}:
+                return True
+
+        return False
+
+    def can_user_edit(self, user):
+        if user.is_applicant:
+            if self.status in {SUBMITTED, CHANGES_REQUESTED}:
+                return True
+
+        if user.is_apply_staff:
+            if self.status in {SUBMITTED}:
+                return True
+
+        return False
+
+    def can_user_change_status(self, user):
+        if not user.is_apply_staff:
+            return False  # Users can't change status
+
+        if self.status in {PAID, DECLINED}:
+            return False
+
+        return True
+
+    @property
+    def value(self):
+        return self.paid_value or self.requested_value
+
+    def get_absolute_url(self):
+        return reverse('apply:projects:payments:detail', args=[self.pk])
+
+
 COMMITTED = 'committed'
 CONTRACTING = 'contracting'
+IN_PROGRESS = 'in_progress'
+CLOSING = 'closing'
+COMPLETE = 'complete'
 PROJECT_STATUS_CHOICES = [
     (COMMITTED, 'Committed'),
     (CONTRACTING, 'Contracting'),
-    ('in_progress', 'In Progress'),
-    ('closing', 'Closing'),
-    ('complete', 'Complete'),
+    (IN_PROGRESS, 'In Progress'),
+    (CLOSING, 'Closing'),
+    (COMPLETE, 'Complete'),
 ]
 
 
-class Project(models.Model):
+class ProjectQuerySet(models.QuerySet):
+    def in_progress(self):
+        return self.exclude(status=COMPLETE)
+
+    def complete(self):
+        return self.filter(status=COMPLETE)
+
+    def in_approval(self):
+        return self.filter(
+            is_locked=True,
+            status=COMMITTED,
+            approvals__isnull=True,
+        )
+
+    def by_end_date(self, desc=False):
+        order = getattr(F('proposed_end'), 'desc' if desc else 'asc')(nulls_last=True)
+
+        return self.order_by(order)
+
+    def with_amount_paid(self):
+        return self.annotate(
+            amount_paid=Coalesce(Sum('payment_requests__paid_value'), V(0)),
+        )
+
+    def with_last_payment(self):
+        return self.annotate(
+            last_payment_request=Max('payment_requests__requested_at'),
+        )
+
+    def for_table(self):
+        return self.with_amount_paid().with_last_payment()
+
+
+class Project(BaseStreamForm, AccessFormData, models.Model):
     lead = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name='lead_projects')
     submission = models.OneToOneField("funds.ApplicationSubmission", on_delete=models.CASCADE)
     user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='owned_projects')
@@ -91,6 +320,9 @@ class Project(models.Model):
 
     status = models.TextField(choices=PROJECT_STATUS_CHOICES, default=COMMITTED)
 
+    form_data = JSONField(encoder=StreamFieldDataEncoder, default=dict)
+    form_fields = StreamField(FormFieldsBlock(), null=True)
+
     # tracks read/write state of the Project
     is_locked = models.BooleanField(default=False)
 
@@ -105,9 +337,17 @@ class Project(models.Model):
     )
     created_at = models.DateTimeField(auto_now_add=True)
 
+    sent_to_compliance_at = models.DateTimeField(null=True)
+
+    objects = ProjectQuerySet.as_manager()
+
     def __str__(self):
         return self.title
 
+    @property
+    def status_display(self):
+        return self.get_status_display()
+
     def get_address_display(self):
         address = json.loads(self.contact_address)
         return ', '.join(
@@ -144,6 +384,12 @@ class Project(models.Model):
             value=submission.form_data.get('value', 0),
         )
 
+    def paid_value(self):
+        return self.payment_requests.paid_value()
+
+    def unpaid_value(self):
+        return self.payment_requests.unpaid_value()
+
     def clean(self):
         if self.proposed_start is None:
             return
@@ -154,6 +400,19 @@ class Project(models.Model):
         if self.proposed_start > self.proposed_end:
             raise ValidationError(_('Proposed End Date must be after Proposed Start Date'))
 
+    def save(self, *args, **kwargs):
+        creating = not self.pk
+
+        if creating:
+            files = self.extract_files()
+        else:
+            self.process_file_data(self.form_data)
+
+        super().save(*args, **kwargs)
+
+        if creating:
+            self.process_file_data(files)
+
     def editable_by(self, user):
         if self.editable:
             return True
@@ -163,6 +422,13 @@ class Project(models.Model):
 
     @property
     def editable(self):
+        if self.status not in (CONTRACTING, COMMITTED):
+            return True
+
+        # Someone has approved the project - consider it locked while with contracting
+        if self.approvals.exists():
+            return False
+
         # Someone must lead the project to make changes
         return self.lead and not self.is_locked
 
@@ -175,6 +441,12 @@ class Project(models.Model):
     def can_make_approval(self):
         return self.is_locked and self.status == COMMITTED
 
+    def can_request_funding(self):
+        """
+        Should we show this Project's funding block?
+        """
+        return self.status in (CLOSING, IN_PROGRESS)
+
     @property
     def can_send_for_approval(self):
         """
@@ -187,6 +459,10 @@ class Project(models.Model):
         correct_state = self.status == COMMITTED and not self.is_locked
         return correct_state and self.user_has_updated_details
 
+    @property
+    def requires_approval(self):
+        return not self.approvals.exists()
+
     def get_missing_document_categories(self):
         """
         Get the number of documents required to meet each DocumentCategorys minimum
@@ -205,6 +481,28 @@ class Project(models.Model):
                     'difference': difference,
                 }
 
+    @property
+    def is_in_progress(self):
+        return self.status == IN_PROGRESS
+
+    def send_to_compliance(self, request):
+        """Notify Compliance about this Project."""
+
+        messenger(
+            MESSAGES.SENT_TO_COMPLIANCE,
+            request=request,
+            user=request.user,
+            source=self,
+        )
+
+        self.sent_to_compliance_at = timezone.now()
+        self.save(update_fields=['sent_to_compliance_at'])
+
+
+@register_setting
+class ProjectSettings(BaseSetting):
+    compliance_email = models.TextField("Compliance Email")
+
 
 class DocumentCategory(models.Model):
     name = models.CharField(max_length=254)
@@ -216,3 +514,16 @@ class DocumentCategory(models.Model):
     class Meta:
         ordering = ('name',)
         verbose_name_plural = 'Document Categories'
+
+
+class ProjectApprovalForm(BaseStreamForm, models.Model):
+    name = models.CharField(max_length=255)
+    form_fields = StreamField(FormFieldsBlock())
+
+    panels = [
+        FieldPanel('name'),
+        StreamFieldPanel('form_fields'),
+    ]
+
+    def __str__(self):
+        return self.name
diff --git a/opentech/apply/projects/tables.py b/opentech/apply/projects/tables.py
new file mode 100644
index 0000000000000000000000000000000000000000..1aaa9d67b08e956983f158be9d74fb0923e47e8a
--- /dev/null
+++ b/opentech/apply/projects/tables.py
@@ -0,0 +1,121 @@
+import textwrap
+
+import django_tables2 as tables
+from django.db.models import F, Sum
+from django.contrib.humanize.templatetags.humanize import intcomma
+
+from .models import PaymentRequest, Project
+
+
+class BasePaymentRequestsTable(tables.Table):
+    project = tables.LinkColumn(
+        'funds:projects:payments:detail',
+        text=lambda r: textwrap.shorten(r.project.title, width=30, placeholder="..."),
+        args=[tables.utils.A('pk')],
+    )
+    status = tables.Column()
+    requested_at = tables.DateColumn(verbose_name='Submitted')
+
+    def render_value(self, value):
+        return f'${value}'
+
+
+class PaymentRequestsDashboardTable(BasePaymentRequestsTable):
+    date_from = tables.DateColumn(verbose_name='Period Start')
+    date_to = tables.DateColumn(verbose_name='Period End')
+
+    class Meta:
+        fields = [
+            'project',
+            'status',
+            'requested_at',
+            'date_from',
+            'date_to',
+            'value',
+        ]
+        model = PaymentRequest
+        orderable = False
+        order_by = ['-requested_at']
+
+
+class PaymentRequestsListTable(BasePaymentRequestsTable):
+    fund = tables.Column(verbose_name="Fund", accessor='project.submission.page')
+    lead = tables.Column(verbose_name="Lead", accessor='project.lead')
+
+    class Meta:
+        fields = [
+            'project',
+            'fund',
+            'lead',
+            'status',
+            'requested_at',
+            'value',
+        ]
+        model = PaymentRequest
+        orderable = True
+        order_by = ['-requested_at']
+
+    def order_value(self, qs, is_descending):
+        direction = '-' if is_descending else ''
+
+        qs = qs.order_by(f'{direction}paid_value', f'{direction}requested_value')
+
+        return qs, True
+
+
+class BaseProjectsTable(tables.Table):
+    title = tables.LinkColumn(
+        'funds:projects:detail',
+        text=lambda r: textwrap.shorten(r.title, width=30, placeholder="..."),
+        args=[tables.utils.A('pk')],
+    )
+    status = tables.Column(verbose_name='Status', accessor='get_status_display', order_by=('status',))
+    fund = tables.Column(verbose_name='Fund', accessor='submission.page')
+    last_payment_request = tables.DateColumn()
+    end_date = tables.DateColumn(verbose_name='End Date', accessor='proposed_end')
+    fund_allocation = tables.Column(verbose_name='Fund Allocation', accessor='value')
+
+    def render_fund_allocation(self, record):
+        return f'${intcomma(record.amount_paid)} / ${intcomma(record.value)}'
+
+
+class ProjectsDashboardTable(BaseProjectsTable):
+    class Meta:
+        fields = [
+            'title',
+            'status',
+            'fund',
+            'last_payment_request',
+            'end_date',
+            'fund_allocation',
+        ]
+        model = Project
+        orderable = False
+
+
+class ProjectsListTable(BaseProjectsTable):
+    class Meta:
+        fields = [
+            'title',
+            'status',
+            'lead',
+            'fund',
+            'last_payment_request',
+            'end_date',
+            'fund_allocation',
+        ]
+        model = Project
+        orderable = True
+        order_by = ('-end_date',)
+
+    def order_fund_allocation(self, qs, is_descending):
+        direction = '-' if is_descending else ''
+
+        qs = qs.annotate(
+            paid_ratio=Sum('payment_requests__paid_value') / F('value'),
+        ).order_by(f'{direction}paid_ratio', f'{direction}value')
+
+        return qs, True
+
+    def order_end_date(self, qs, desc):
+        return qs.by_end_date(desc), True
diff --git a/opentech/apply/projects/templates/application_projects/includes/funding_block.html b/opentech/apply/projects/templates/application_projects/includes/funding_block.html
new file mode 100644
index 0000000000000000000000000000000000000000..c5a1459fe73eb43e5eb1fc3abe8d452cf20c740d
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/includes/funding_block.html
@@ -0,0 +1,17 @@
+{% load humanize payment_request_tools %}
+<ul class="funding-block">
+    <li class="funding-block__item">
+        <p class="funding-block__title">Fund total</p>
+        <p class="funding-block__standout">${{ object.value|intcomma }}</p>
+    </li>
+    <li class="funding-block__item">
+        <p class="funding-block__title">Total paid</p>
+        <p class="funding-block__standout">${{ object.paid_value|intcomma }}</p>
+        <p class="funding-block__meta">({% percentage object.paid_value object.value %}%)</p>
+    </li>
+    <li class="funding-block__item">
+        <p class="funding-block__title">Awaiting payment</p>
+        <p class="funding-block__standout">${{ object.unpaid_value|intcomma}}</p>
+        <p class="funding-block__meta">({% percentage object.unpaid_value object.value %}%)</p>
+    </li>
+</ul>
diff --git a/opentech/apply/funds/templates/funds/includes/invoice_block.html b/opentech/apply/projects/templates/application_projects/includes/invoice_block.html
similarity index 100%
rename from opentech/apply/funds/templates/funds/includes/invoice_block.html
rename to opentech/apply/projects/templates/application_projects/includes/invoice_block.html
diff --git a/opentech/apply/projects/templates/application_projects/includes/payment_requests.html b/opentech/apply/projects/templates/application_projects/includes/payment_requests.html
new file mode 100644
index 0000000000000000000000000000000000000000..940cd498aae764b52bcc889bcca71295a47cda28
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/includes/payment_requests.html
@@ -0,0 +1,95 @@
+{% load payment_request_tools humanize %}
+
+<div id="payment-requests" class="payment-block">
+    <div class="payment-block__header">
+        <p class="payment-block__title">Payment Requests</p>
+        <a class="payment-block__button button button--primary"
+           href="{% url "apply:projects:request" pk=object.pk %}">
+            Add Request
+        </a>
+    </div>
+
+    <table class="payment-block__table">
+        <thead>
+            <tr>
+                <th class="payment-block__table-amount">Amount</th>
+                <th class="payment-block__table-status">Status</th>
+                <th class="payment-block__table-date">From</th>
+                <th class="payment-block__table-date">To</th>
+                <th class="payment-block__table-update"></th>
+            </tr>
+        </thead>
+        <tbody>
+            {% for payment_request in object.payment_requests.not_rejected %}
+            <tr>
+                <td><span class="payment-block__mobile-label">Amount: </span>${{ payment_request.value|intcomma }}</td>
+                <td><span class="payment-block__mobile-label">Status: </span>{{ payment_request.get_status_display }}</td>
+                <td><span class="payment-block__mobile-label">From: </span>{{ payment_request.date_from.date }}</td>
+                <td><span class="payment-block__mobile-label">To: </span>{{ payment_request.date_to.date }}</td>
+                <td>
+                    <a href="{{ payment_request.get_absolute_url }}">View</a>
+                    {% can_edit payment_request user as user_can_edit_request %}
+                    {% if user_can_edit_request %}
+                    <a href="{% url "apply:projects:payments:edit" pk=payment_request.pk %}">
+                        Edit
+                    </a>
+                    {% endif %}
+
+                    {% can_delete payment_request user as user_can_delete_request %}
+                    {% if user_can_delete_request %}
+                    <a href="{% url 'apply:projects:payments:delete' pk=payment_request.pk %}">
+                        Delete
+                    </a>
+                    {% endif %}
+                </td>
+            </tr>
+            {% empty %}
+            <tr>
+                <td colspan="5">No active Payment Requests.</td>
+            </tr>
+            {% endfor %}
+        </tbody>
+    </table>
+
+    {% if object.payment_requests.rejected %}
+        <p class="payment-block__rejected">
+            <a class="payment-block__rejected-link js-payment-block-rejected-link" href="#">Show rejected</a>
+        </p>
+
+        <table class="payment-block__table is-hidden js-payment-block-rejected-table">
+            <thead>
+                <tr>
+                    <th class="payment-block__table-amount">Amount</th>
+                    <th class="payment-block__table-status">Status</th>
+                    <th class="payment-block__table-view"></th>
+                </tr>
+            </thead>
+            <tbody>
+                {% for payment_request in object.payment_requests.rejected %}
+                <tr>
+                    <td><span class="payment-block__mobile-label">Amount: </span>${{ payment_request.value }}</td>
+                    <td><span class="payment-block__mobile-label">Status: </span>{{ payment_request.get_status_display }}</td>
+                    <td><a href="{{ payment_request.get_absolute_url }}">View</a></td>
+                </tr>
+                {% endfor %}
+            </tbody>
+        </table>
+    {% endif %}
+
+</div>
+
+{% for form in change_payment_request_status_forms %}
+<div class="modal" id="change-payment-status-{{ form.instance.pk }}">
+    <h4 class="modal__header-bar">Change payment status</h4>
+    <div class="wrapper--outer-space-medium">
+        <p>Current status: <b>{{ form.instance.get_status_display }}</b></p>
+
+        {% if not form.instance.has_changes_requested %}
+        <p>Requested value: <b>${{ form.instance.requested_value }}</b></p>
+        {% endif %}
+
+        {% url 'apply:projects:change-payment-status' pk=object.pk payment_request_id=form.instance.pk as change_payment_status_action %}
+        {% include 'funds/includes/delegated_form_base.html' with form=form value='Update' action=change_payment_status_action %}
+    </div>
+</div>
+{% endfor %}
diff --git a/opentech/apply/projects/templates/application_projects/includes/paymentrequest_admin_detail.html b/opentech/apply/projects/templates/application_projects/includes/paymentrequest_admin_detail.html
new file mode 100644
index 0000000000000000000000000000000000000000..d5fd9a177e2fca985dad7fb769d4616316fc872a
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/includes/paymentrequest_admin_detail.html
@@ -0,0 +1,28 @@
+{% extends "application-projects/paymentrequest_detail.html" %}
+
+{% block actions %}
+    {{ block.super }}
+    <a
+        data-fancybox
+        data-src="#change-status"
+        class="button button--bottom-space button--primary button--full-width"
+        href="#"
+    >
+        Change Status
+    </a>
+    <div class="modal" id="change-status">
+        <h4 class="modal__header-bar">Change status</h4>
+        <p>Current status: {{ object.get_status_display }}</p>
+        {% include 'funds/includes/delegated_form_base.html' with form=change_payment_status value='Update'%}
+    </div>
+{% endblock %}
+
+{% block extra_css %}
+<link rel="stylesheet" href="{% static 'css/apply/fancybox.css' %}">
+{% endblock %}
+
+{% block extra_js %}
+{{ block.super }}
+<script src="//cdnjs.cloudflare.com/ajax/libs/fancybox/3.4.1/jquery.fancybox.min.js"></script>
+<script src="{% static 'js/apply/fancybox-global.js' %}"></script>
+{% endblock %}
diff --git a/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html b/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html
index 8e3e1acc21c727fc912a6082bbc4aa8143343eba..597258a5b0414070b7ce555d54a5718a1ced06b5 100644
--- a/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html
+++ b/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html
@@ -13,8 +13,10 @@
                 <p class="docs-block__title">Proposal</p>
             </div>
             <div class="docs-block__row-inner">
-                <a class="docs-block__link" href="#">View</a>
-                <a class="docs-block__link" href="#">Download</a>
+                <a class="docs-block__link" href="{% url 'apply:submissions:simplified' pk=project.submission.pk %}">View</a>
+                {% if not user.is_applicant %}
+                    <a class="docs-block__link" href="#">Download</a>
+                {% endif %}
             </div>
         </li>
 
@@ -35,8 +37,8 @@
                         {% endif %}
                     </a>
                 {% endif %}
-                {% if object.user_has_updated_details %}
-                    <a class="docs-block__link" href="#">
+                {% if object.user_has_updated_details and not user.is_applicant %}
+                    <a class="docs-block__link" href="{% url 'apply:projects:simplified' pk=project.pk %}">
                         View
                     </a>
                 {% endif %}
@@ -45,13 +47,26 @@
 
         <li class="docs-block__row">
             <div class="docs-block__row-inner">
-                <svg class="icon docs-block__icon"><use xlink:href="#tick"></use></svg>
+                <svg class="icon docs-block__icon{% if not remaining_document_categories %} is-complete{% endif %}">
+                    <use xlink:href="#tick"></use>
+                </svg>
                 <p class="docs-block__title">Supporting documents</p>
             </div>
             {% if editable %}
-                <div class="docs-block__row-inner">
-                    <a data-fancybox data-src="#upload-supporting-doc" class="docs-block__link" href="#">Upload new</a>
-                </div>
+            <div class="docs-block__row-inner">
+                {% if select_document_form.should_show %}
+                    <a
+                        data-fancybox
+                        data-src="#copy-supporting-doc"
+                        class="docs-block__link"
+                        href="#">
+                        Choose file
+                    </a>
+                {% else %}
+                    <span class="docs-block__link is-disabled" data-tooltip="No files on submission">Choose file</span>
+                {% endif %}
+                <a data-fancybox data-src="#upload-supporting-doc" class="docs-block__link" href="#">Upload new</a>
+            </div>
             {% endif %}
             {% if remaining_document_categories %}
             <div class="docs-block__info-text">
@@ -76,11 +91,11 @@
                     </div>
                     <div class="docs-block__document-inner">
                         <a class="docs-block__document-link" href="{% url 'apply:projects:document' pk=object.pk file_pk=document.pk %}">Download</a>
-                        <form method="POST" id="{{ remove_document_form.name }}">
+                        <form method="POST" id="{{ remove_document_form.name }}" class="docs-block__document-form">
                             {% csrf_token %}
                             {{ document.get_remove_form }}
                             <input
-                                class="button button--primary button--top-space"
+                                class="button button--link"
                                 id="{{ remove_document_form.name }}-submit"
                                 name="{{ form_prefix }}{{ remove_document_form.name }}"
                                 type="submit"
@@ -97,7 +112,8 @@
         </li>
     </ul>
     <div class="docs-block__buttons">
-        {% if object.can_send_for_approval %}
+        {% can_send_for_approval object user as can_approve %}
+        {% if can_approve %}
             <a data-fancybox
             data-src="#send-for-approval"
             class="button button--primary"
@@ -105,11 +121,17 @@
                 Submit for Approval
             </a>
         {% endif %}
-        <!-- <button class="button button--primary" href="#">Ready for contracting</button> -->
     </div>
 </div>
 
+<div class="modal" id="copy-supporting-doc">
+    <h4 class="modal__header-bar">Select a document</h4>
+    {% url 'apply:projects:copy-documents' pk=object.pk as select_document_url %}
+    {% include 'funds/includes/delegated_form_base.html' with form=select_document_form value='Copy' action=select_document_url %}
+</div>
+
 <div class="modal" id="upload-supporting-doc">
     <h4 class="modal__header-bar">Upload a new document</h4>
+    <p></p>
     {% include 'funds/includes/delegated_form_base.html' with form=document_form value='Upload'%}
 </div>
diff --git a/opentech/apply/projects/templates/application_projects/overview.html b/opentech/apply/projects/templates/application_projects/overview.html
new file mode 100644
index 0000000000000000000000000000000000000000..6adc7c31c7fa1a93580e53243aba9a11d2c182d5
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/overview.html
@@ -0,0 +1,72 @@
+{% extends "base-apply.html" %}
+
+{% load render_table from django_tables2 %}
+{% load static %}
+
+{% block title %}Projects{% endblock %}
+
+{% block extra_css %}
+<link rel="stylesheet" href="{% static 'css/apply/fancybox.css' %}">
+{{ projects.filterset.form.media.css }}
+{% endblock %}
+
+{% block content %}
+<div class="admin-bar">
+    <div class="admin-bar__inner wrapper--search">
+        {% block page_header %}
+            <div>
+                <h1 class="gamma heading heading--no-margin heading--bold">Projects</h1>
+                <h5>Track and explore recent projects</h5>
+            </div>
+        {% endblock %}
+    </div>
+</div>
+
+<div class="wrapper wrapper--large wrapper--inner-space-medium">
+
+    {% include "funds/includes/status-block.html" with type="Projects" %}
+
+    {% if projects.table.data %}
+    <div class="wrapper wrapper--bottom-space">
+
+        {% include "funds/includes/table_filter_and_search.html" with filter=projects.filterset filter_action=projects.url search_term=search_term search_placeholder="projects" search_action=projects.url use_search=True use_batch_actions=False heading="Projects" %}
+
+        {% render_table projects.table %}
+
+        <div class="all-submissions-table__more">
+            <a href="{{ projects.url }}">Show all</a>
+        </div>
+
+    </div>
+    {% endif %}
+
+    {% if payment_requests.table.data %}
+    <div class="wrapper wrapper--bottom-space">
+
+        {% include "funds/includes/table_filter_and_search.html" with filter=payment_requests.filterset filter_action=payment_requests.url search_term=search_term search_placeholder="payment requests" search_action=payment_requests.url use_search=True use_batch_actions=False heading="Payment Requests" %}
+
+        {% render_table payment_requests.table %}
+
+        <div class="all-submissions-table__more">
+            <a href="{{ payment_requests.url }}">Show all</a>
+        </div>
+
+    </div>
+    {% endif %}
+
+</div>
+
+{% endblock %}
+
+{% block extra_js %}
+    {{ projects.filterset.form.media.js }}
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/fancybox/3.4.1/jquery.fancybox.min.js"></script>
+    <script src="{% static 'js/apply/fancybox-global.js' %}"></script>
+    <script src="{% static 'js/apply/all-submissions-table.js' %}"></script>
+    <script src="https://cdn.jsdelivr.net/npm/symbol-es6@0.1.2/symbol-es6.min.js"></script>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/url-search-params/1.1.0/url-search-params.js"></script>
+    <script src="{% static 'js/apply/submission-filters.js' %}"></script>
+    <script src="{% static 'js/apply/submission-tooltips.js' %}"></script>
+    <script src="{% static 'js/apply/tabs.js' %}"></script>
+    <script src="{% static 'js/apply/batch-actions.js' %}"></script>
+{% endblock %}
diff --git a/opentech/apply/projects/templates/application_projects/payment_request_list.html b/opentech/apply/projects/templates/application_projects/payment_request_list.html
new file mode 100644
index 0000000000000000000000000000000000000000..99fd1135ee847bfe52ee1c9d9d6cc3976c120d87
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/payment_request_list.html
@@ -0,0 +1,38 @@
+{% extends "base-apply.html" %}
+
+{% load render_table from django_tables2 %}
+{% load static %}
+
+{% block title %}Payment Requests{% endblock %}
+
+{% block content %}
+<div class="admin-bar">
+    <div class="admin-bar__inner wrapper--search">
+        {% block page_header %}
+            <div>
+                <h1 class="gamma heading heading--no-margin heading--bold">All Payment Requests</h1>
+            </div>
+        {% endblock %}
+    </div>
+</div>
+
+<div class="wrapper wrapper--large wrapper--inner-space-medium">
+    {% if table %}
+    {% include "funds/includes/table_filter_and_search.html" with filter_form=filter_form search_term=search_term use_search=True filter_action=filter_action use_batch_actions=True search_placeholder="payment requests" %}
+    {% render_table table %}
+    {% else %}
+    <p>No Requests Available</p>
+    {% endif %}
+</div>
+
+{% endblock content %}
+
+{% block extra_css %}
+    <link rel="stylesheet" href="{% static 'css/apply/fancybox.css' %}">
+    {{ filter.form.media.css }}
+{% endblock %}
+
+{% block extra_js %}
+    {{ filter.form.media.js }}
+    <script src="{% static 'js/apply/submission-filters.js' %}"></script>
+{% endblock %}
diff --git a/opentech/apply/projects/templates/application_projects/paymentrequest_admin_detail.html b/opentech/apply/projects/templates/application_projects/paymentrequest_admin_detail.html
new file mode 100644
index 0000000000000000000000000000000000000000..edfdd7fc6f4a3e136e0062c7733845b5c52f9fd9
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/paymentrequest_admin_detail.html
@@ -0,0 +1,36 @@
+{% extends "application_projects/paymentrequest_detail.html" %}
+{% load static payment_request_tools %}
+
+{% block actions %}
+    {{ block.super }}
+    {% can_change_status object user as user_can_change_status %}
+    <a
+        {% if user_can_change_status %}
+            data-fancybox
+            data-src="#change-status"
+        {% else %}
+            data-tooltip="Cannot change from 'Paid' or 'Declined' state"
+        {% endif %}
+        class="button button--bottom-space button--primary button--full-width{% if not user_can_change_status %} button--tooltip-disabled{% endif %}"
+        href="#"
+    >
+        Change Status
+    </a>
+    {% if user_can_change_status %}
+    <div class="modal" id="change-status">
+        <h4 class="modal__header-bar">Change status</h4>
+        <p>Current status: {{ object.get_status_display }}</p>
+        {% include 'funds/includes/delegated_form_base.html' with form=change_payment_status value='Update'%}
+    </div>
+    {% endif %}
+{% endblock %}
+
+{% block extra_css %}
+<link rel="stylesheet" href="{% static 'css/apply/fancybox.css' %}">
+{% endblock %}
+
+{% block extra_js %}
+{{ block.super }}
+<script src="//cdnjs.cloudflare.com/ajax/libs/fancybox/3.4.1/jquery.fancybox.min.js"></script>
+<script src="{% static 'js/apply/fancybox-global.js' %}"></script>
+{% endblock %}
diff --git a/opentech/apply/projects/templates/application_projects/paymentrequest_confirm_delete.html b/opentech/apply/projects/templates/application_projects/paymentrequest_confirm_delete.html
new file mode 100644
index 0000000000000000000000000000000000000000..d3907d95c8c673b7f77bde5cbe41056eaca946ed
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/paymentrequest_confirm_delete.html
@@ -0,0 +1,37 @@
+
+{% extends "base-apply.html" %}
+{% load humanize payment_request_tools %}
+
+{% block title %}Payment Request: {{ object.project.title }}{% endblock %}
+{% block content %}
+<div class="admin-bar">
+    <div class="admin-bar__inner">
+        <a class="simplified__projects-link" href="{{ object.project.get_absolute_url }}">
+            Project
+        </a>
+        <h2 class="heading heading--no-margin">Delete Payment Request</h2>
+        <h5 class="heading heading--no-margin">For: {{ object.project.title }}</h5>
+    </div>
+</div>
+
+<div class="wrapper wrapper--sidebar wrapper--outer-space-medium">
+    <div class="wrapper--sidebar--inner">
+
+        <div class="card card--solid">
+            <p class="card__text"><b>Status:</b> {{ object.get_status_display }}</p>
+            <p class="card__text"><b>Name of Vendor:</b> {{ object.project.contact_legal_name }}</p>
+            <p class="card__text"><b>Invoice Number:</b> {{ object.pk }}</p>
+            <p class="card__text"><b>Period of Performance:</b> {{ object.date_from.date }} | {{ object.date_to.date }}</p>
+            <p class="card__text"><b>Total:</b> ${{ object.value|intcomma }}</p>
+
+        </div>
+        <div class="card card--solid">
+            <form method="post">{% csrf_token %}
+                <p>Are you sure you want to delete this payment request for {{ object.project.title }}?</p>
+                <input class="button button--primary" type="submit" value="Confirm">
+            </form>
+
+        </div>
+    </div>
+</div>
+{% endblock %}
diff --git a/opentech/apply/projects/templates/application_projects/paymentrequest_detail.html b/opentech/apply/projects/templates/application_projects/paymentrequest_detail.html
new file mode 100644
index 0000000000000000000000000000000000000000..eb034ad8488de6311e5e479b52786e75a0caafc7
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/paymentrequest_detail.html
@@ -0,0 +1,66 @@
+{% extends "base-apply.html" %}
+{% load humanize payment_request_tools %}
+
+{% block title %}Payment Request: {{ object.project.title }}{% endblock %}
+{% block content %}
+<div class="admin-bar">
+    <div class="admin-bar__inner">
+        <a class="simplified__projects-link" href="{{ object.project.get_absolute_url }}">
+            Project
+        </a>
+        <h2 class="heading heading--no-margin">Payment Request</h2>
+        <h5 class="heading heading--no-margin">For: {{ object.project.title }}</h5>
+    </div>
+</div>
+
+<div class="wrapper wrapper--sidebar wrapper--outer-space-medium">
+    <div class="wrapper--sidebar--inner">
+        <div class="card card--solid">
+            <p class="card__text"><b>Status:</b> {{ object.get_status_display }}</p>
+            <p class="card__text"><b>Name of Vendor:</b> {{ object.project.contact_legal_name }}</p>
+            <p class="card__text"><b>Invoice Number:</b> {{ object.pk }}</p>
+            <p class="card__text"><b>Period of Performance:</b> {{ object.date_from.date }} | {{ object.date_to.date }}</p>
+            <p class="card__text"><b>Total:</b> ${{ object.value|intcomma }}</p>
+        </div>
+
+        <div class="card card--solid">
+            <div class="card__inner">
+                <h5 class="card__heading">Invoice</h5>
+                <p class="card__text"><a href="{% url "apply:projects:payments:invoice" pk=object.pk %}">Download</a></p>
+            </div>
+            <div class="card__inner">
+                <h5 class="card__heading">Reciepts</h5>
+                {% for reciept in object.receipts.all %}
+                    <p class="card__text"><a href="{% url "apply:projects:payments:receipt" pk=object.pk file_pk=reciept.pk %}">Download</a></p>
+                {% endfor %}
+            </div>
+        </div>
+    </div>
+    <aside class="sidebar">
+        <div class="js-actions-sidebar sidebar__inner sidebar__inner--light-blue sidebar__inner--actions">
+            {% block actions %}
+                {% can_edit object user as user_can_edit_request %}
+                <a
+                    {% if not user_can_edit_request %}
+                        data-tooltip="Only editable when 'Submitted' or you have been requested to make changes"
+                    {% endif %}
+                    class="button button--bottom-space button--primary button--full-width{% if not user_can_edit_request %} button--tooltip-disabled{% endif %}"
+                    href={% if user_can_edit_request %}
+                        "{% url "apply:projects:payments:edit" pk=object.pk %}"
+                    {% else %}
+                        "#"
+                    {% endif %}
+                >
+                    Edit
+                </a>
+                {% can_delete object user as user_can_delete_request %}
+                {% if user_can_delete_request %}
+                <a
+                    class="button button--bottom-space button--primary button--full-width"
+                    href="{% url 'apply:projects:payments:delete' pk=object.pk %}">Delete</a>
+            {% endif %}
+            {% endblock %}
+        </div>
+    </aside>
+</div>
+{% endblock %}
diff --git a/opentech/apply/projects/templates/application_projects/paymentrequest_form.html b/opentech/apply/projects/templates/application_projects/paymentrequest_form.html
new file mode 100644
index 0000000000000000000000000000000000000000..c0b49835b25a1217083a498e88db0c0eb3ee2616
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/paymentrequest_form.html
@@ -0,0 +1,36 @@
+{% extends "base-apply.html" %}
+{% load static %}
+
+{% block title %}{% if object %}Edit{% else %}Create{% endif %} Payment Request: {% if object %}{{ object.project.title }}{% else %}{{ project.title }}{% endif %}{% endblock %}
+{% block content %}
+<div class="admin-bar">
+    <div class="admin-bar__inner">
+        <h2 class="heading heading--no-margin">{% if object %}Editing{% else %}Create{% endif %} Payment Request</h2>
+        <h5 class="heading heading--no-margin">{% if object %}{{ object.project.title }}{% else %}{{ project.title }}{% endif %}</h5>
+    </div>
+</div>
+
+{% include "forms/includes/form_errors.html" with form=form %}
+
+<div class="wrapper wrapper--light-grey-bg wrapper--form wrapper--sidebar">
+    <div class="wrapper--sidebar--inner">
+        <form class="form" action="" method="post" enctype="multipart/form-data">
+            {% csrf_token %}
+            {{ form.media }}
+
+            {% for field in form %}
+                {% if field.field %}
+                    {% include "forms/includes/field.html" %}
+                {% else %}
+                    {{ field }}
+                {% endif %}
+            {% endfor %}
+            <input class="button button--submit button--top-space button--primary" type="submit" name="save" value="Save" />
+        </form>
+    </div>
+</div>
+{% endblock %}
+
+{% block extra_js %}
+<script src="{% static 'js/apply/list-input-files.js' %}"></script>
+{% endblock %}
diff --git a/opentech/apply/projects/templates/application_projects/project_admin_detail.html b/opentech/apply/projects/templates/application_projects/project_admin_detail.html
index 239d18fb9c923900e6e6dac011a4193b54be5821..ffafd3731869879201c6675b6fa2530ac3fe07c9 100644
--- a/opentech/apply/projects/templates/application_projects/project_admin_detail.html
+++ b/opentech/apply/projects/templates/application_projects/project_admin_detail.html
@@ -3,7 +3,7 @@
 {% load approval_tools %}
 {% load static %}
 
-{% block admin_sidebar %}
+{% block admin_actions %}
 <div class="modal" id="send-for-approval">
     <h4 class="modal__header-bar">Request Approval</h4>
 
@@ -30,6 +30,8 @@
 
 <div class="modal" id="approve">
     <h4 class="modal__header-bar">Add Approval</h4>
+    <p>This will move the project into contracting and notify the compliance team.</p>
+    <p>This cannot be undone</p>
     {% include 'funds/includes/delegated_form_base.html' with form=add_approval_form value='Approve'%}
 </div>
 
@@ -38,67 +40,68 @@
     {% include 'funds/includes/delegated_form_base.html' with form=rejection_form value='Request Changes'%}
 </div>
 
-{% if mobile %}
-    <a class="js-actions-toggle button button--white button--full-width button--actions">Actions to take</a>
+{% if contract_to_approve %}
+<div class="modal" id="approve-contract">
+    <h4 class="modal__header-bar">Approve Contract</h4>
+    <p>You confirm that the uploaded contract is acceptable for commencing the project.</p>
+    <p>This cannot be undone.</p>
+    {% include 'funds/includes/delegated_form_base.html' with form=approve_contract_form value='Confirm' %}
+</div>
 {% endif %}
 
-<div class="js-actions-sidebar sidebar__inner sidebar__inner--light-blue sidebar__inner--actions {% if mobile %}sidebar__inner--mobile{% endif %}">
-
-    <h5>Actions to take</h5>
-
-    {% if object.can_send_for_approval %}
-    <a data-fancybox
-       data-src="#send-for-approval"
-       class="button button--bottom-space button--primary button--full-width"
-       href="#">
+{% if object.requires_approval %}
+    <a
+        {% if not object.can_send_for_approval %}
+            onclick="return false"
+            data-tooltip="
+                {% if not object.lead %}
+                    A lead must be assigned
+                {% elif not object.user_has_updated_details %}
+                    Project approval form must be completed
+                {% elif object.is_locked %}
+                    Currently awaiting approval
+                {% endif %}"
+        {% endif %}
+        data-fancybox
+        data-src="#send-for-approval"
+        class="button button--bottom-space button--primary button--full-width {% if not object.can_send_for_approval %}button--tooltip-disabled{% endif %}"
+        href="#">
         Submit for Approval
     </a>
-    {% endif %}
-
-    {% if object.can_make_approval %}
-        {% user_can_approve_project object request.user as user_can_approve %}
-        <a data-fancybox
-            data-src="#approve"
-            class="button button--bottom-space button--primary button--full-width {% if user_can_approve %}is-not-disabled{% else %}is-disabled{% endif %}"
-            href="#">
-            Approve
-        </a>
-
-        <a data-fancybox
-            data-src="#request-project-changes"
-           class="button button--bottom-space button--primary button--full-width {% if user_can_approve %}is-not-disabled{% else %}is-disabled{% endif %}"
-            href="#">
-            Request changes
-        </a>
-    {% endif %}
-
-    <!-- <a data-fancybox -->
-    <!-- data-src="#ready-for-contracting" -->
-    <!-- class="button button--primary button--full-width" -->
-    <!-- href="#"> -->
-    <!-- Ready for contracting -->
-    <!-- </a> -->
+{% endif %}
 
+{% if object.can_make_approval %}
+  {% user_can_approve_project object request.user as user_can_approve %}
+  <a data-fancybox
+      data-src="#approve"
+      class="button button--bottom-space button--primary button--full-width {% if user_can_approve %}is-not-disabled{% else %}is-disabled{% endif %}"
+      href="#">
+      Approve
+  </a>
+
+  <a data-fancybox
+      data-src="#request-project-changes"
+     class="button button--bottom-space button--primary button--full-width {% if user_can_approve %}is-not-disabled{% else %}is-disabled{% endif %}"
+      href="#">
+      Request changes
+  </a>
+{% endif %}
 
-    <p class="sidebar__separator">Assign</p>
+{% endblock %}
 
-    <a data-fancybox
-       data-src="#assign-lead"
-       class="button button--bottom-space button--white button--full-width"
-       href="#">
-        Lead
-    </a>
+{% block admin_assignments %}
+<p class="sidebar__separator">Assign</p>
 
-    <!-- <a data-fancybox -->
-    <!-- data-src="#update-meta-categories" -->
-    <!-- class="button button--bottom-space button--white button--full-width" -->
-    <!-- href="#"> -->
-    <!-- Meta Categories -->
-    <!-- </a> -->
+<a data-fancybox
+ data-src="#assign-lead"
+ class="button button--bottom-space button--white button--full-width"
+ href="#">
+  Lead
+</a>
 
-</div>
+{% endblock %}
 
-{% if approvals %}
+{% block project_approvals %}
 <div class="sidebar__inner">
     <h5>Approved By:</h5>
 
@@ -106,15 +109,18 @@
     <p>{{ approval.by }} - {{ approval.created_at|date:"Y-m-d" }}</p>
     {% endfor %}
 </div>
-{% endif %}
-
-{% endblock %}
-
-{% block extra_css %}
-    <link rel="stylesheet" href="{% static 'css/apply/fancybox.css' %}">
 {% endblock %}
 
-{% block extra_js %}
+{% block approve_contract %}
+{% if contract_to_approve %}
     {{ block.super }}
-    <script src="//cdnjs.cloudflare.com/ajax/libs/fancybox/3.4.1/jquery.fancybox.min.js"></script>
+    <p>
+        <a data-fancybox
+        data-src="#approve-contract"
+        class="button button--bottom-space button--primary button--full-width"
+        href="#">
+            Approve Contract
+        </a>
+    </p>
+{% endif %}
 {% endblock %}
diff --git a/opentech/apply/projects/templates/application_projects/project_applicant_detail.html b/opentech/apply/projects/templates/application_projects/project_applicant_detail.html
index 2ca4b45978990b5f19dc4d1b5537fd11141a2aa4..d7e3d95b391a77c85ad79dfeefb4f5fdebf3298c 100644
--- a/opentech/apply/projects/templates/application_projects/project_applicant_detail.html
+++ b/opentech/apply/projects/templates/application_projects/project_applicant_detail.html
@@ -2,17 +2,15 @@
 
 {% block notifications %}
 {% if not object.editable %}
-<div class="wrapper wrapper--sidebar">
-    <div class="wrapper--sidebar--inner wrapper--error">
-        <div>
-            <p>Your project is not editable at this point.</p>
+    <div class="error-bar">
+        <p class="error-bar__copy">
+            Your project is not editable at this point.
             {% if not object.lead %}
-                <p>We are awaiting a lead to be assigned.</p>
+            We are awaiting a lead to be assigned.
             {% else %}
-                <p>It is currently under review by a staff member.</p>
+                It is currently under review by a staff member.
             {% endif %}
-        </div>
+        </p>
     </div>
-</div>
 {% endif %}
 {% endblock %}
diff --git a/opentech/apply/projects/templates/application_projects/project_detail.html b/opentech/apply/projects/templates/application_projects/project_detail.html
index 73ef24b09b9a8ede66a101f35258632a2ba07b28..de8cabd3568a81024811c88e2f42cfca7060fe51 100644
--- a/opentech/apply/projects/templates/application_projects/project_detail.html
+++ b/opentech/apply/projects/templates/application_projects/project_detail.html
@@ -1,5 +1,6 @@
 {% extends "base-apply.html" %}
 
+{% load contract_tools %}
 {% load static  %}
 {% load wagtailcore_tags %}
 
@@ -34,7 +35,24 @@
             <span>Lead: {{ object.lead }}</span>
 
         </h5>
-        {# {% status_bar object.workflow object.phase request.user same_stage=True%} #}
+
+        <div class="status-bar">
+            {% for status, text in statuses %}
+                {% if forloop.counter0 == current_status_index %}
+                    {% include "funds/includes/status_bar_item.html" with is_current=True is_complete=False label=text %}
+                {% elif forloop.counter0 < current_status_index %}
+                    {% include "funds/includes/status_bar_item.html" with is_current=False is_complete=True label=text %}
+                {% else %}
+                    {% include "funds/includes/status_bar_item.html" with is_current=False is_complete=False label=text %}
+                {% endif %}
+            {% endfor %}
+        </div>
+
+        <div class="status-bar--mobile">
+            <h6 class="status-bar__subheading">
+                {{ object.get_status_display }}
+            </h6>
+        </div>
 
         <div class="tabs js-tabs">
             <div class="tabs__container">
@@ -106,28 +124,115 @@
                     </div>
                 </div>
 
-                {# <div class="wrapper wrapper--outer-space-large"> #}
-                {#     {% include "funds/includes/funding_block.html" %} #}
-                {#     {% include "funds/includes/payment_requests.html" %} #}
-                {#     {% include "funds/includes/invoice_block.html" %} #}
-                {# </div> #}
+                {% if object.can_request_funding %}
+                <div class="wrapper wrapper--outer-space-large">
+                    {% include "application_projects/includes/funding_block.html" %}
+                    {% include "application_projects/includes/payment_requests.html" %}
+                </div>
+                {% endif %}
 
+                {% if not object.is_in_progress %}
                 {% include "application_projects/includes/supporting_documents.html" %}
+                {% endif %}
             </article>
 
             <aside class="sidebar">
-                {% if request.user.is_apply_staff %}
-                {% block admin_sidebar %}{% endblock %}
+                {% if mobile %}
+                <a class="js-actions-toggle button button--white button--full-width button--actions">Actions to take</a>
                 {% endif %}
 
+                <div class="js-actions-sidebar sidebar__inner sidebar__inner--light-blue sidebar__inner--actions {% if mobile %}sidebar__inner--mobile{% endif %}">
+
+                    <h5>Actions to take</h5>
+
+                    {% if request.user.is_apply_staff %}
+                    {% block admin_actions %}{% endblock %}
+                    {% endif %}
+
+                    {% user_can_upload_contract object request.user as can_upload_contract %}
+                    {% if can_upload_contract %}
+                    <div class="modal" id="upload-contract">
+                        {% if not user.is_staff %}
+                            <h4 class="modal__header-bar">Upload Signed Contract</h4>
+                        {% else %}
+                            <h4 class="modal__header-bar">Upload Contract</h4>
+                        {% endif %}
+                        {% include 'funds/includes/delegated_form_base.html' with form=contract_form value='Upload'%}
+                    </div>
+
+                    <a data-fancybox
+                       data-src="#upload-contract"
+                       class="button button--primary button--full-width button--bottom-space"
+                       href="#">
+                        Upload contract
+                    </a>
+                    {% endif %}
+
+                    {% if object.can_request_funding %}
+                    <a class="button button--primary button--bottom-space button--full-width"
+                       href="{% url "apply:projects:request" pk=object.pk %}">
+                        Add payment request
+                    </a>
+                    {% endif %}
+
+                    {% if request.user.is_apply_staff %}
+                    {% block admin_assignments %}{% endblock %}
+                    {% endif %}
+
+                </div>
+
+                {% if object.is_in_progress %}
                 <div class="sidebar__inner">
-                    <h5>Meta Categories</h5>
+                    <h5>Supporting Information</h5>
 
-                    <p>Meta Category</p>
-                    <p>Meta Category</p>
-                    <p>Meta Category</p>
+                    <p><a class="link link--bold" href="{{ object.submission.get_absolute_url }}">Proposal</a></p>
+
+                    {% if request.user.is_apply_staff %}
+                    <p><a class="link link--bold" href="{% url 'apply:projects:simplified' pk=project.pk %}">Approval form</a></p>
+                    {% endif %}
                 </div>
+                {% endif %}
+
+                {% if request.user.is_apply_staff and approvals %}
+                {% block project_approvals %}{% endblock %}
+                {% endif %}
+
+                {% if contracts or contract_to_sign or contract_to_approve %}
+                <div class="sidebar__inner">
+                    <h5>Contracts</h5>
 
+                    {% block approve_contract %}
+                        {% if contract_to_approve %}
+                        <p>
+                            <a href="{{ contract_to_approve.file.url }}" class="link link--bold">
+                                {{ contract_to_approve.created_at|date:"j F Y" }}
+                            </a>
+                            (Signed)
+                        </p>
+                        {% endif %}
+                    {% endblock %}
+
+                    {% block sign_contract %}
+                        {% if contract_to_sign%}
+                        <p>
+                            <a href="{{ contract_to_sign.file.url }}" class="link link--bold">
+                                {{ contract_to_sign.created_at|date:"j F Y" }}
+                            </a>
+                            (Unsigned)
+                        </p>
+                        {% endif %}
+                    {% endblock %}
+
+                    {% for contract in contracts %}
+                    <p>
+                        <a href="{{ contract.file.url }}" class="link link--bold">
+                            {{ contract.created_at|date:"j F Y" }}
+                        </a>
+                            - Approved by {{ contract.approver }}
+                    </p>
+                    {% endfor %}
+                </div>
+                {% endif %}
             </aside>
         </div>
     </div>
@@ -149,8 +254,13 @@
 </div>
 {% endblock content %}
 
+{% block extra_css %}
+    <link rel="stylesheet" href="{% static 'css/apply/fancybox.css' %}">
+{% endblock %}
+
 {% block extra_js %}
     <script src="{% static 'js/apply/tabs.js' %}"></script>
+    <script src="{% static 'js/apply/toggle-payment-block.js' %}"></script>
     <script src="{% static 'js/apply/toggle-proposal-info.js' %}"></script>
     <script src="{% static 'js/apply/file-uploads.js' %}"></script>
     <script src="//cdnjs.cloudflare.com/ajax/libs/fancybox/3.4.1/jquery.fancybox.min.js"></script>
diff --git a/opentech/apply/projects/templates/application_projects/project_list.html b/opentech/apply/projects/templates/application_projects/project_list.html
new file mode 100644
index 0000000000000000000000000000000000000000..81df6e6570d47b02032d51be8eb9102bf5269451
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/project_list.html
@@ -0,0 +1,38 @@
+{% extends "base-apply.html" %}
+
+{% load render_table from django_tables2 %}
+{% load static %}
+
+{% block title %}Projects{% endblock %}
+
+{% block content %}
+<div class="admin-bar">
+    <div class="admin-bar__inner wrapper--search">
+        {% block page_header %}
+            <div>
+                <h1 class="gamma heading heading--no-margin heading--bold">All Projects</h1>
+            </div>
+        {% endblock %}
+    </div>
+</div>
+
+<div class="wrapper wrapper--large wrapper--inner-space-medium">
+    {% if table %}
+    {% include "funds/includes/table_filter_and_search.html" with filter_form=filter_form search_term=search_term search_placeholder="projects" use_search=True filter_action=filter_action use_batch_actions=True %}
+    {% render_table table %}
+    {% else %}
+    <p>No Projects Available</p>
+    {% endif %}
+</div>
+
+{% endblock content %}
+
+{% block extra_css %}
+    <link rel="stylesheet" href="{% static 'css/apply/fancybox.css' %}">
+    {{ filter.form.media.css }}
+{% endblock %}
+
+{% block extra_js %}
+    {{ filter.form.media.js }}
+    <script src="{% static 'js/apply/submission-filters.js' %}"></script>
+{% endblock %}
diff --git a/opentech/apply/projects/templates/application_projects/project_simplified_detail.html b/opentech/apply/projects/templates/application_projects/project_simplified_detail.html
new file mode 100644
index 0000000000000000000000000000000000000000..d09ea1aa48e0c21c04c5d7a7583c0b126deeba4e
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/project_simplified_detail.html
@@ -0,0 +1,129 @@
+{% extends "base-apply.html" %}
+
+{% block title %}{{ object.title }}{% endblock %}
+
+{% block body_class %}light-grey-bg{% endblock %}
+
+{% block content %}
+<div class="simplified">
+    <div class="simplified__admin-bar">
+        <div class="simplified__admin-bar-inner">
+            <a class="simplified__projects-link" href="{{ object.get_absolute_url }}">
+                Project
+            </a>
+            <h1 class="simplified__heading">{{ object.title }}</h1>
+            <h5 class="simplified__subheading">
+                <span>{{ object.submission.page }}</span>
+                <span>{{ object.submission.round }}</span>
+                <span>Lead: {{ object.lead }}</span>
+            </h5>
+        </div>
+    </div>
+
+    <div class="simplified__wrapper">
+        <h3>Project Information</h3>
+        <div class="card card--solid">
+            <div class="grid grid--proposal-info">
+                <div>
+                    <h5>Proposed start date</h5>
+                    <p>{{ object.proposed_start|date:"j F Y"|default:"-" }}</p>
+                </div>
+
+                <div>
+                    <h5>Project Proposed end date</h5>
+                    <p>{{ object.proposed_end|date:"j F Y"|default:"-" }}</p>
+                </div>
+
+                <div>
+                    <h5>Legal name</h5>
+                    <p>{{ object.contact_legal_name|default:"-" }}</p>
+                </div>
+
+                <div>
+                    <h5>Email</h5>
+                    <p>{{ object.contact_email|default:"-" }}</p>
+                </div>
+
+                <div>
+                    <h5>Address</h5>
+                    <p>{{ object.get_address_display|default:"-"}}</p>
+                </div>
+
+                <div>
+                    <h5>Phone</h5>
+                    <p>{{ object.phone|default:"-" }}</p>
+                </div>
+
+                <div>
+                    <h5>Value</h5>
+                    <p>${{ object.value|default:"-" }}</p>
+                </div>
+
+                {% if object.sent_to_compliance_at %}
+                <div>
+                    <h5>Sent to Compliance</h5>
+                    <p>{{ object.sent_to_compliance_at|date:"j F Y" }}</p>
+                </div>
+                {% endif %}
+
+            </div>
+
+            {% if object.output_answers %}
+                <div class="simplified__rich-text">
+                    {{ object.output_answers }}
+                </div>
+            {% endif %}
+        </div>
+
+        <h3>Approvals</h3>
+        <div class="card card--solid">
+            <h4>Approver</h4>
+            {% with approval=project.approvals.first %}
+                <p>{{ approval.by }} ({{ approval.created_at }})</p>
+            {% endwith %}
+        </div>
+
+        <h3>Review</h3>
+        <div class="card card--solid">
+            <h4>Submission lead</h4>
+            <p>{{ project.submission.lead }}</p>
+
+            <h4>Reviews</h4>
+            <h5>Staff Reviewers</h5>
+            {% for review in project.submission.reviews.by_staff %}
+            <div class="card__reviewer-outcome">
+                <div class="traffic-light traffic-light--{{ review.outcome|lower }}"></div>
+                <span class="card__reviewer">
+                    {{ review.author }}
+                    {% if review.author.role %}
+                        as {{ review.author.role }}
+                    {% endif %}
+                    - {{ review.outcome }} ({{ review.created_at }})
+                </span>
+            </div>
+            {% empty %}
+                No reviews
+            {% endfor %}
+            <h5>External Reviewers</h5>
+            {% for review in project.submission.reviews.by_reviewers %}
+                <div class="card__reviewer-outcome">
+                    <div class="traffic-light traffic-light--{{ review.outcome|lower }}"></div>
+                    <span class="card__reviewer">
+                        {{ review.author }} - <span>{{ review.outcome }}</span> ({{ review.created_at }})
+                    </span>
+                </div>
+            {% empty %}
+                No reviews
+            {% endfor %}
+        </div>
+
+        <h3>Supporting Documents</h3>
+        <div class="card card--solid">
+            <p><a href="{% url 'apply:submissions:simplified' pk=object.submission_id %}">Submission</a></p>
+            {% for packet_file in object.packet_files.all %}
+                <p><a href="{% url 'apply:projects:document' pk=object.pk file_pk=packet_file.pk %}">{{ packet_file.title }}</a></p>
+            {% endfor %}
+        </div>
+    </div>
+</div>
+{% endblock content %}
diff --git a/opentech/apply/projects/templatetags/approval_tools.py b/opentech/apply/projects/templatetags/approval_tools.py
index 2c51d1a59462bedbe371bdc18f82c75a6598196a..17de68f47c17ac66f37eca927e38484db404f2ef 100644
--- a/opentech/apply/projects/templatetags/approval_tools.py
+++ b/opentech/apply/projects/templatetags/approval_tools.py
@@ -8,6 +8,11 @@ def user_has_approved(project, user):
     return project.approvals.filter(by=user).exists()
 
 
+@register.simple_tag
+def can_send_for_approval(project, user):
+    return user.is_staff and project.can_send_for_approval
+
+
 @register.simple_tag
 def user_can_approve_project(project, user):
     return user.is_approver and not user_has_approved(project, user)
diff --git a/opentech/apply/projects/templatetags/contract_tools.py b/opentech/apply/projects/templatetags/contract_tools.py
new file mode 100644
index 0000000000000000000000000000000000000000..6197b5f03d0d2503bb24686c90cb3b555e56b66f
--- /dev/null
+++ b/opentech/apply/projects/templatetags/contract_tools.py
@@ -0,0 +1,28 @@
+from django import template
+
+from ..models import COMMITTED
+
+register = template.Library()
+
+
+@register.simple_tag
+def user_can_upload_contract(project, user):
+    if user.is_apply_staff:
+        return project.status != COMMITTED
+
+    # Does the Project have any unapproved contracts?
+    latest_contract = project.contracts.order_by('-created_at').first()
+
+    # No contract ever uploaded - nothing to do
+    if not latest_contract:
+        return False
+
+    # Latest contract approved - nothing to do
+    if latest_contract.approver:
+        return False
+
+    # Contract is either:
+    #  - Unsigned: Applicant needs to sign it.
+    #  - Signed: Applicant is waiting on approval and may need to upload a new
+    #    version because my scanning was bad.
+    return True
diff --git a/opentech/apply/projects/templatetags/payment_request_tools.py b/opentech/apply/projects/templatetags/payment_request_tools.py
new file mode 100644
index 0000000000000000000000000000000000000000..310e8a0bf4712762b03a5c5b70b449b48e5c9b02
--- /dev/null
+++ b/opentech/apply/projects/templatetags/payment_request_tools.py
@@ -0,0 +1,36 @@
+import decimal
+
+from django import template
+
+register = template.Library()
+
+
+@register.simple_tag
+def can_change_status(payment_request, user):
+    return payment_request.can_user_change_status(user)
+
+
+@register.simple_tag
+def can_delete(payment_request, user):
+    return payment_request.can_user_delete(user)
+
+
+@register.simple_tag
+def can_edit(payment_request, user):
+    return payment_request.can_user_edit(user)
+
+
+@register.simple_tag
+def percentage(value, total):
+    if not total:
+        return decimal.Decimal(0)
+
+    unrounded_total = (value / total) * 100
+
+    # round using Decimal since we're dealing with currency
+    rounded_total = unrounded_total.quantize(
+        decimal.Decimal('0.0'),
+        rounding=decimal.ROUND_DOWN,
+    )
+
+    return rounded_total
diff --git a/opentech/apply/projects/tests/factories.py b/opentech/apply/projects/tests/factories.py
index b91c0aadf1e1c82f41884838451e7852c18018be..23a535ed867e3b44325ac24cb1b5d692c0f67c5b 100644
--- a/opentech/apply/projects/tests/factories.py
+++ b/opentech/apply/projects/tests/factories.py
@@ -2,17 +2,22 @@ import decimal
 import json
 
 import factory
+import pytz
 from django.utils import timezone
 
 from opentech.apply.funds.tests.factories import ApplicationSubmissionFactory
 from opentech.apply.projects.models import (
+    Contract,
     DocumentCategory,
     PacketFile,
+    PaymentReceipt,
+    PaymentRequest,
     Project,
+    ProjectApprovalForm,
 )
+from opentech.apply.stream_forms.testing.factories import FormDataFactory, FormFieldsBlockFactory
 from opentech.apply.users.tests.factories import StaffFactory, UserFactory
 
-
 ADDRESS = {
     'country': 'GB',
     'thoroughfare': factory.Faker('street_name').generate({}),
@@ -47,6 +52,18 @@ class DocumentCategoryFactory(factory.DjangoModelFactory):
         model = DocumentCategory
 
 
+class ProjectApprovalFormFactory(factory.DjangoModelFactory):
+    class Meta:
+        model = ProjectApprovalForm
+
+    name = factory.Faker('word')
+    form_fields = FormFieldsBlockFactory
+
+
+class ProjectApprovalFormDataFactory(FormDataFactory):
+    field_factory = FormFieldsBlockFactory
+
+
 class ProjectFactory(factory.DjangoModelFactory):
     submission = factory.SubFactory(ApplicationSubmissionFactory)
     user = factory.SubFactory(UserFactory)
@@ -63,9 +80,30 @@ class ProjectFactory(factory.DjangoModelFactory):
 
     is_locked = False
 
+    form_fields = FormFieldsBlockFactory
+    form_data = factory.SubFactory(
+        ProjectApprovalFormDataFactory,
+        form_fields=factory.SelfAttribute('..form_fields'),
+    )
+
     class Meta:
         model = Project
 
+    class Params:
+        in_approval = factory.Trait(
+            is_locked=True,
+        )
+
+
+class ContractFactory(factory.DjangoModelFactory):
+    approver = factory.SubFactory(StaffFactory)
+    project = factory.SubFactory(ProjectFactory)
+
+    file = factory.django.FileField()
+
+    class Meta:
+        model = Contract
+
 
 class PacketFileFactory(factory.DjangoModelFactory):
     category = factory.SubFactory(DocumentCategoryFactory)
@@ -76,3 +114,26 @@ class PacketFileFactory(factory.DjangoModelFactory):
 
     class Meta:
         model = PacketFile
+
+
+class PaymentRequestFactory(factory.DjangoModelFactory):
+    project = factory.SubFactory(ProjectFactory)
+    by = factory.SubFactory(UserFactory)
+    requested_value = factory.Faker('pydecimal', min_value=1, max_value=10000000, right_digits=2)
+
+    date_from = factory.Faker('date_time').generate({'tzinfo': pytz.utc})
+    date_to = factory.Faker('date_time').generate({'tzinfo': pytz.utc})
+
+    invoice = factory.django.FileField()
+
+    class Meta:
+        model = PaymentRequest
+
+
+class PaymentReceiptFactory(factory.DjangoModelFactory):
+    payment_request = factory.SubFactory(PaymentRequestFactory)
+
+    file = factory.django.FileField()
+
+    class Meta:
+        model = PaymentReceipt
diff --git a/opentech/apply/projects/tests/test_files.py b/opentech/apply/projects/tests/test_files.py
new file mode 100644
index 0000000000000000000000000000000000000000..83c585477f0b7e9ce169802e0c821d77c818c084
--- /dev/null
+++ b/opentech/apply/projects/tests/test_files.py
@@ -0,0 +1,44 @@
+from django.core.files import File
+from django.test import TestCase
+from more_itertools import collapse
+
+from opentech.apply.stream_forms.files import StreamFieldFile
+
+from ..files import flatten, get_files
+from .factories import ProjectFactory
+
+
+class TestFlatten(TestCase):
+    def test_no_items(self):
+        with self.assertRaises(TypeError):
+            list(flatten(1))
+
+    def test_one_level_of_items(self):
+        output = list(flatten([1, 2, 3]))
+        self.assertEqual(output, [1, 2, 3])
+
+    def test_two_levels_of_items(self):
+        output = list(flatten([1, [2, 3]]))
+        self.assertEqual(output, [1, 2, 3])
+
+    def test_three_levels_of_items(self):
+        output = list(flatten([1, [2, (3)]]))
+        self.assertEqual(output, [1, 2, (3)])
+
+
+class TestGetFiles(TestCase):
+    def test_get_files(self):
+        project = ProjectFactory()
+
+        files = list(get_files(project))
+
+        self.assertTrue(all(issubclass(f.__class__, File) for f in files))
+
+        fields = project.submission.form_data.values()
+        fields = collapse(fields, base_type=StreamFieldFile)
+        fields = [f for f in fields if isinstance(f, StreamFieldFile)]
+
+        self.assertEqual(len(files), len(fields))
+
+        for f in files:
+            self.assertIn(f, fields)
diff --git a/opentech/apply/projects/tests/test_forms.py b/opentech/apply/projects/tests/test_forms.py
index 5c0d157d5b81315841a9134cb98551d30d50d1b7..1e24303d66a3e9dc44b60e07d0a90fadc7108f64 100644
--- a/opentech/apply/projects/tests/test_forms.py
+++ b/opentech/apply/projects/tests/test_forms.py
@@ -1,7 +1,88 @@
-from django.test import TestCase
+from io import BytesIO
+from unittest import mock
 
-from ..forms import ProjectApprovalForm
-from .factories import ProjectFactory, address_to_form_data
+from django.core.files.uploadedfile import SimpleUploadedFile
+from django.test import TestCase, override_settings
+
+from opentech.apply.users.tests.factories import (
+    UserFactory,
+)
+
+from ..files import get_files
+from ..forms import (
+    ChangePaymentRequestStatusForm,
+    CreatePaymentRequestForm,
+    ProjectApprovalForm,
+    SelectDocumentForm,
+    StaffUploadContractForm,
+    UploadContractForm,
+    filter_choices,
+    filter_request_choices
+)
+from ..models import (
+    CHANGES_REQUESTED,
+    DECLINED,
+    PAID,
+    SUBMITTED,
+    UNDER_REVIEW,
+)
+from .factories import (
+    DocumentCategoryFactory,
+    PaymentRequestFactory,
+    ProjectFactory,
+    address_to_form_data
+)
+
+
+class TestChangePaymentRequestStatusForm(TestCase):
+    def test_choices_with_submitted_status(self):
+        request = PaymentRequestFactory(status=SUBMITTED)
+        form = ChangePaymentRequestStatusForm(instance=request)
+
+        expected = set(filter_request_choices([UNDER_REVIEW, CHANGES_REQUESTED, DECLINED]))
+        actual = set(form.fields['status'].choices)
+
+        self.assertEqual(expected, actual)
+
+    def test_choices_with_changes_requested_status(self):
+        request = PaymentRequestFactory(status=CHANGES_REQUESTED)
+        form = ChangePaymentRequestStatusForm(instance=request)
+
+        expected = set(filter_request_choices([DECLINED]))
+        actual = set(form.fields['status'].choices)
+
+        self.assertEqual(expected, actual)
+
+    def test_choices_with_under_review_status(self):
+        request = PaymentRequestFactory(status=UNDER_REVIEW)
+        form = ChangePaymentRequestStatusForm(instance=request)
+
+        expected = set(filter_request_choices([PAID]))
+        actual = set(form.fields['status'].choices)
+
+        self.assertEqual(expected, actual)
+
+    def test_filter_choices(self):
+        ONE = 'one'
+        TWO = 'two'
+        choices = [
+            (ONE, 'One'),
+            (TWO, 'Two'),
+        ]
+
+        output = filter_choices(choices, [ONE, TWO])
+        self.assertTrue(output, choices)
+
+        # order shouldn't matter
+        output = filter_choices(choices, [TWO, ONE])
+        self.assertTrue(output, choices)
+
+        # duplicates shouldn't matter
+        output = filter_choices(choices, [TWO, ONE, TWO])
+        self.assertTrue(output, choices)
+
+        output = filter_choices(choices, [TWO])
+        self.assertTrue(output, [(TWO, 'Two')])
 
 
 class TestProjectApprovalForm(TestCase):
@@ -21,6 +102,122 @@ class TestProjectApprovalForm(TestCase):
         }
         data.update(address_to_form_data())
         form = ProjectApprovalForm(instance=project, data=data)
+        self.assertTrue(form.is_valid(), form.errors.as_text())
+
         form.save()
 
         self.assertTrue(project.user_has_updated_details)
+
+
+class TestCreatePaymentRequestForm(TestCase):
+    def test_adding_payment_request(self):
+        data = {
+            'requested_value': '10',
+            'date_from': '2018-08-15',
+            'date_to': '2019-08-15',
+            'comment': 'test comment',
+        }
+
+        invoice = SimpleUploadedFile('invoice.pdf', BytesIO(b'somebinarydata').read())
+        receipts = SimpleUploadedFile('receipts.pdf', BytesIO(b'someotherbinarydata').read())
+        files = {
+            'invoice': invoice,
+            'receipts': receipts,
+        }
+
+        form = CreatePaymentRequestForm(data=data, files=files)
+        self.assertTrue(form.is_valid(), msg=form.errors)
+
+        form.instance.by = UserFactory()
+        form.instance.project = ProjectFactory()
+        payment_request = form.save()
+
+        self.assertEqual(payment_request.receipts.count(), 1)
+
+    def test_payment_request_dates_are_correct(self):
+        invoice = SimpleUploadedFile('invoice.pdf', BytesIO(b'somebinarydata').read())
+        receipts = SimpleUploadedFile('receipts.pdf', BytesIO(b'someotherbinarydata').read())
+        files = {
+            'invoice': invoice,
+            'receipts': receipts,
+        }
+
+        form = CreatePaymentRequestForm(
+            files=files,
+            data={
+                'requested_value': '10',
+                'date_from': '2018-08-15',
+                'date_to': '2019-08-15',
+                'comment': 'test comment',
+            }
+        )
+        self.assertTrue(form.is_valid(), msg=form.errors)
+
+        form = CreatePaymentRequestForm(
+            files=files,
+            data={
+                'requested_value': '10',
+                'date_from': '2019-08-15',
+                'date_to': '2018-08-15',
+                'comment': 'test comment',
+            }
+        )
+        self.assertFalse(form.is_valid())
+
+
+@override_settings(ROOT_URLCONF='opentech.apply.urls')
+class TestSelectDocumentForm(TestCase):
+    def test_copying_files(self):
+        category = DocumentCategoryFactory()
+        project = ProjectFactory()
+
+        self.assertEqual(project.packet_files.count(), 0)
+
+        files = list(get_files(project))
+        self.assertEqual(len(files), 4)
+
+        url = files[3].url
+
+        form = SelectDocumentForm(
+            files,
+            data={'category': category.id, 'document': url},
+        )
+        self.assertTrue(form.is_valid(), form.errors)
+
+        form.instance.project = project
+        form.save()
+
+        packet_files = project.packet_files.order_by('id')
+        self.assertEqual(len(packet_files), 1)
+
+        self.assertEqual(packet_files.first().document.read(), files[3].read())
+
+
+class TestStaffContractUploadForm(TestCase):
+    mock_file = mock.MagicMock(spec=SimpleUploadedFile)
+    mock_file.read.return_value = b"fake file contents"
+
+    def test_staff_can_upload_unsigned(self):
+        form = StaffUploadContractForm(data={}, files={'file': self.mock_file})
+        self.assertTrue(form.is_valid(), form.errors)
+        self.assertFalse(form.cleaned_data.get('is_signed'))
+
+    def test_staff_can_upload_signed(self):
+        form = StaffUploadContractForm(data={'is_signed': True}, files={'file': self.mock_file})
+        self.assertTrue(form.is_valid(), form.errors)
+        self.assertTrue(form.cleaned_data.get('is_signed'))
+
+
+class TestContractUploadForm(TestCase):
+    mock_file = mock.MagicMock(spec=SimpleUploadedFile)
+    mock_file.read.return_value = b"fake file contents"
+
+    def test_applicant_cant_upload_unsigned(self):
+        form = UploadContractForm(data={}, files={'file': self.mock_file})
+        self.assertTrue(form.is_valid(), form.errors)
+        self.assertIsNone(form.cleaned_data.get('is_signed'))
+
+    def test_applicant_can_upload_signed(self):
+        form = UploadContractForm(data={'is_signed': True}, files={'file': self.mock_file})
+        self.assertTrue(form.is_valid(), form.errors)
+        self.assertIsNone(form.cleaned_data.get('is_signed'))
diff --git a/opentech/apply/projects/tests/test_models.py b/opentech/apply/projects/tests/test_models.py
index ded83fe644999416caf85afe0ef196c180b707fd..a467f9f1dfa2af32f22ea50197d41a8e1da1d56a 100644
--- a/opentech/apply/projects/tests/test_models.py
+++ b/opentech/apply/projects/tests/test_models.py
@@ -1,10 +1,25 @@
+from decimal import Decimal
+
 from django.test import TestCase
 
 from opentech.apply.funds.tests.factories import ApplicationSubmissionFactory
-
-from ..models import Project
-from .factories import (DocumentCategoryFactory, PacketFileFactory,
-                        ProjectFactory)
+from opentech.apply.users.tests.factories import ApplicantFactory, StaffFactory
+
+from ..models import (
+    CHANGES_REQUESTED,
+    DECLINED,
+    PAID,
+    SUBMITTED,
+    UNDER_REVIEW,
+    Project,
+    PaymentRequest,
+)
+from .factories import (
+    DocumentCategoryFactory,
+    PacketFileFactory,
+    PaymentRequestFactory,
+    ProjectFactory
+)
 
 
 class TestProjectModel(TestCase):
@@ -59,3 +74,97 @@ class TestProjectModel(TestCase):
         self.assertEqual(missing[0]['difference'], 3)
         self.assertEqual(missing[1]['category'], category2)
         self.assertEqual(missing[1]['difference'], 2)
+
+
+class TestPaymentRequestModel(TestCase):
+    def test_staff_can_delete_from_submitted(self):
+        payment_request = PaymentRequestFactory(status=SUBMITTED)
+        staff = StaffFactory()
+
+        self.assertTrue(payment_request.can_user_delete(staff))
+
+    def test_staff_cant_delete_from_under_review(self):
+        payment_request = PaymentRequestFactory(status=UNDER_REVIEW)
+        staff = StaffFactory()
+
+        self.assertFalse(payment_request.can_user_delete(staff))
+
+    def test_staff_cant_delete_from_changes_requested(self):
+        payment_request = PaymentRequestFactory(status=CHANGES_REQUESTED)
+        staff = StaffFactory()
+
+        self.assertFalse(payment_request.can_user_delete(staff))
+
+    def test_staff_cant_delete_from_paid(self):
+        payment_request = PaymentRequestFactory(status=PAID)
+        staff = StaffFactory()
+
+        self.assertFalse(payment_request.can_user_delete(staff))
+
+    def test_staff_cant_delete_from_declined(self):
+        payment_request = PaymentRequestFactory(status=DECLINED)
+        staff = StaffFactory()
+
+        self.assertFalse(payment_request.can_user_delete(staff))
+
+    def test_can_user_delete_from_submitted(self):
+        payment_request = PaymentRequestFactory(status=SUBMITTED)
+        user = ApplicantFactory()
+
+        self.assertTrue(payment_request.can_user_delete(user))
+
+    def test_user_cant_delete_from_under_review(self):
+        payment_request = PaymentRequestFactory(status=UNDER_REVIEW)
+        user = ApplicantFactory()
+
+        self.assertFalse(payment_request.can_user_delete(user))
+
+    def test_user_can_delete_from_changes_requested(self):
+        payment_request = PaymentRequestFactory(status=CHANGES_REQUESTED)
+        user = ApplicantFactory()
+
+        self.assertTrue(payment_request.can_user_delete(user))
+
+    def test_user_cant_delete_from_paid(self):
+        payment_request = PaymentRequestFactory(status=PAID)
+        user = ApplicantFactory()
+
+        self.assertFalse(payment_request.can_user_delete(user))
+
+    def test_user_cant_delete_from_declined(self):
+        payment_request = PaymentRequestFactory(status=DECLINED)
+        user = ApplicantFactory()
+
+        self.assertFalse(payment_request.can_user_delete(user))
+
+    def test_requested_value_used_when_no_paid_value(self):
+        payment_request = PaymentRequestFactory(
+            requested_value=Decimal('1'),
+            paid_value=None,
+        )
+        self.assertEqual(payment_request.value, Decimal('1'))
+
+    def test_paid_value_overrides_requested_value(self):
+        payment_request = PaymentRequestFactory(
+            requested_value=Decimal('1'),
+            paid_value=Decimal('2'),
+        )
+        self.assertEqual(payment_request.value, Decimal('2'))
+
+        payment_request = PaymentRequestFactory(
+            requested_value=Decimal('100'),
+            paid_value=Decimal('2'),
+        )
+        self.assertEqual(payment_request.value, Decimal('2'))
+
+
+class TestPaymentRequestsQueryset(TestCase):
+    def test_get_totals(self):
+        PaymentRequestFactory(requested_value=20)
+        PaymentRequestFactory(paid_value=10, status=PAID)
+        self.assertEqual(PaymentRequest.objects.paid_value(), 10)
+        self.assertEqual(PaymentRequest.objects.unpaid_value(), 20)
+
+    def test_get_totals_no_value(self):
+        self.assertEqual(PaymentRequest.objects.paid_value(), 0)
+        self.assertEqual(PaymentRequest.objects.unpaid_value(), 0)
diff --git a/opentech/apply/projects/tests/test_templatetags.py b/opentech/apply/projects/tests/test_templatetags.py
new file mode 100644
index 0000000000000000000000000000000000000000..72bccc8e31d43e8aeea13d309b4f6266eddfe1f3
--- /dev/null
+++ b/opentech/apply/projects/tests/test_templatetags.py
@@ -0,0 +1,257 @@
+from django.test import TestCase
+
+from opentech.apply.users.tests.factories import ApplicantFactory, StaffFactory
+
+from ..models import (
+    CHANGES_REQUESTED,
+    CLOSING,
+    COMMITTED,
+    COMPLETE,
+    CONTRACTING,
+    DECLINED,
+    IN_PROGRESS,
+    PAID,
+    SUBMITTED,
+    UNDER_REVIEW
+)
+from ..templatetags.contract_tools import user_can_upload_contract
+from ..templatetags.payment_request_tools import (
+    can_change_status,
+    can_delete,
+    can_edit
+)
+from .factories import ContractFactory, PaymentRequestFactory, ProjectFactory
+
+
+class TestContractTools(TestCase):
+    def test_staff_can_upload_after_state_leaves_committed(self):
+        staff = StaffFactory()
+
+        project = ProjectFactory(status=COMMITTED)
+        self.assertFalse(user_can_upload_contract(project, staff))
+
+        project = ProjectFactory(status=CONTRACTING)
+        self.assertTrue(user_can_upload_contract(project, staff))
+
+        project = ProjectFactory(status=IN_PROGRESS)
+        self.assertTrue(user_can_upload_contract(project, staff))
+
+        project = ProjectFactory(status=COMPLETE)
+        self.assertTrue(user_can_upload_contract(project, staff))
+
+        project = ProjectFactory(status=CLOSING)
+        self.assertTrue(user_can_upload_contract(project, staff))
+
+    def test_user_can_only_upload_during_contracting(self):
+        applicant = ApplicantFactory()
+
+        project = ProjectFactory(status=COMMITTED)
+        ContractFactory(project=project, is_signed=True, approver=None)
+        self.assertTrue(user_can_upload_contract(project, applicant))
+
+        project = ProjectFactory(status=CONTRACTING)
+        ContractFactory(project=project, is_signed=True, approver=None)
+        self.assertTrue(user_can_upload_contract(project, applicant))
+
+        project = ProjectFactory(status=IN_PROGRESS)
+        ContractFactory(project=project, is_signed=True, approver=None)
+        self.assertTrue(user_can_upload_contract(project, applicant))
+
+        project = ProjectFactory(status=COMPLETE)
+        ContractFactory(project=project, is_signed=True, approver=None)
+        self.assertTrue(user_can_upload_contract(project, applicant))
+
+        project = ProjectFactory(status=CLOSING)
+        ContractFactory(project=project, is_signed=True, approver=None)
+        self.assertTrue(user_can_upload_contract(project, applicant))
+
+    def test_user_cannot_upload_first_contract(self):
+        applicant = ApplicantFactory()
+
+        project = ProjectFactory(status=CONTRACTING)
+        self.assertFalse(user_can_upload_contract(project, applicant))
+
+    def test_user_cannot_upload_when_latest_is_approved(self):
+        applicant = ApplicantFactory()
+        staff = StaffFactory()
+
+        project = ProjectFactory(status=CONTRACTING)
+        ContractFactory(project=project, is_signed=True, approver=staff)
+        self.assertFalse(user_can_upload_contract(project, applicant))
+
+    def test_user_upload_happy_path(self):
+        applicant = ApplicantFactory()
+
+        project = ProjectFactory(status=CONTRACTING)
+        ContractFactory(project=project, is_signed=True, approver=None)
+        self.assertTrue(user_can_upload_contract(project, applicant))
+
+        project = ProjectFactory(status=IN_PROGRESS)
+        ContractFactory(project=project, is_signed=True, approver=None)
+        self.assertTrue(user_can_upload_contract(project, applicant))
+
+
+class TestPaymentRequestTools(TestCase):
+    def test_staff_can_change_status_from_submitted(self):
+        payment_request = PaymentRequestFactory(status=SUBMITTED)
+        staff = StaffFactory()
+
+        self.assertTrue(can_change_status(payment_request, staff))
+
+    def test_staff_can_change_status_from_under_review(self):
+        payment_request = PaymentRequestFactory(status=UNDER_REVIEW)
+        staff = StaffFactory()
+
+        self.assertTrue(can_change_status(payment_request, staff))
+
+    def test_staff_can_change_status_from_changes_requested(self):
+        payment_request = PaymentRequestFactory(status=CHANGES_REQUESTED)
+        staff = StaffFactory()
+
+        self.assertTrue(can_change_status(payment_request, staff))
+
+    def test_staff_cant_change_status_from_paid(self):
+        payment_request = PaymentRequestFactory(status=PAID)
+        staff = StaffFactory()
+
+        self.assertFalse(can_change_status(payment_request, staff))
+
+    def test_staff_cant_change_status_from_declined(self):
+        payment_request = PaymentRequestFactory(status=DECLINED)
+        staff = StaffFactory()
+
+        self.assertFalse(can_change_status(payment_request, staff))
+
+    def test_user_cant_change_status_from_submitted(self):
+        payment_request = PaymentRequestFactory(status=SUBMITTED)
+        user = ApplicantFactory()
+
+        self.assertFalse(can_change_status(payment_request, user))
+
+    def test_user_cant_change_status_from_under_review(self):
+        payment_request = PaymentRequestFactory(status=UNDER_REVIEW)
+        user = ApplicantFactory()
+
+        self.assertFalse(can_change_status(payment_request, user))
+
+    def test_user_cant_change_status_from_changes_requested(self):
+        payment_request = PaymentRequestFactory(status=CHANGES_REQUESTED)
+        user = ApplicantFactory()
+
+        self.assertFalse(can_change_status(payment_request, user))
+
+    def test_user_cant_change_status_from_paid(self):
+        payment_request = PaymentRequestFactory(status=PAID)
+        user = ApplicantFactory()
+
+        self.assertFalse(can_change_status(payment_request, user))
+
+    def test_user_cant_change_status_from_declined(self):
+        payment_request = PaymentRequestFactory(status=DECLINED)
+        user = ApplicantFactory()
+
+        self.assertFalse(can_change_status(payment_request, user))
+
+    def test_staff_can_delete_from_submitted(self):
+        payment_request = PaymentRequestFactory(status=SUBMITTED)
+        staff = StaffFactory()
+
+        self.assertTrue(can_delete(payment_request, staff))
+
+    def test_staff_cant_delete_from_under_review(self):
+        payment_request = PaymentRequestFactory(status=UNDER_REVIEW)
+        staff = StaffFactory()
+
+        self.assertFalse(can_delete(payment_request, staff))
+
+    def test_staff_cant_delete_from_changes_requested(self):
+        payment_request = PaymentRequestFactory(status=CHANGES_REQUESTED)
+        staff = StaffFactory()
+
+        self.assertFalse(can_delete(payment_request, staff))
+
+    def test_staff_cant_delete_from_paid(self):
+        payment_request = PaymentRequestFactory(status=PAID)
+        staff = StaffFactory()
+
+        self.assertFalse(can_delete(payment_request, staff))
+
+    def test_staff_cant_delete_from_declined(self):
+        payment_request = PaymentRequestFactory(status=DECLINED)
+        staff = StaffFactory()
+
+        self.assertFalse(can_delete(payment_request, staff))
+
+    def test_user_can_delete_from_submitted(self):
+        payment_request = PaymentRequestFactory(status=SUBMITTED)
+        user = ApplicantFactory()
+
+        self.assertTrue(can_delete(payment_request, user))
+
+    def test_user_cant_delete_from_under_review(self):
+        payment_request = PaymentRequestFactory(status=UNDER_REVIEW)
+        user = ApplicantFactory()
+
+        self.assertFalse(can_delete(payment_request, user))
+
+    def test_user_can_delete_from_changes_requested(self):
+        payment_request = PaymentRequestFactory(status=CHANGES_REQUESTED)
+        user = ApplicantFactory()
+
+        self.assertTrue(can_delete(payment_request, user))
+
+    def test_user_cant_delete_from_paid(self):
+        payment_request = PaymentRequestFactory(status=PAID)
+        user = ApplicantFactory()
+
+        self.assertFalse(can_delete(payment_request, user))
+
+    def test_user_cant_delete_from_declined(self):
+        payment_request = PaymentRequestFactory(status=DECLINED)
+        user = ApplicantFactory()
+
+        self.assertFalse(can_delete(payment_request, user))
+
+    def test_applicant_and_staff_can_edit_in_submitted(self):
+        payment_request = PaymentRequestFactory(status=SUBMITTED)
+        applicant = ApplicantFactory()
+        staff = StaffFactory()
+
+        self.assertTrue(can_edit(payment_request, applicant))
+        self.assertTrue(can_edit(payment_request, staff))
+
+    def test_applicant_can_edit_in_changes_requested(self):
+        payment_request = PaymentRequestFactory(status=CHANGES_REQUESTED)
+        applicant = ApplicantFactory()
+
+        self.assertTrue(can_edit(payment_request, applicant))
+
+    def test_staff_cant_edit_in_changes_requested(self):
+        payment_request = PaymentRequestFactory(status=CHANGES_REQUESTED)
+        staff = StaffFactory()
+
+        self.assertFalse(can_edit(payment_request, staff))
+
+    def test_applicant_and_staff_cant_edit_in_under_review(self):
+        payment_request = PaymentRequestFactory(status=UNDER_REVIEW)
+        applicant = ApplicantFactory()
+        staff = StaffFactory()
+
+        self.assertFalse(can_edit(payment_request, applicant))
+        self.assertFalse(can_edit(payment_request, staff))
+
+    def test_applicant_and_staff_cant_edit_in_paid(self):
+        payment_request = PaymentRequestFactory(status=PAID)
+        applicant = ApplicantFactory()
+        staff = StaffFactory()
+
+        self.assertFalse(can_edit(payment_request, applicant))
+        self.assertFalse(can_edit(payment_request, staff))
+
+    def test_applicant_and_staff_cant_edit_in_decline(self):
+        payment_request = PaymentRequestFactory(status=DECLINED)
+        applicant = ApplicantFactory()
+        staff = StaffFactory()
+
+        self.assertFalse(can_edit(payment_request, applicant))
+        self.assertFalse(can_edit(payment_request, staff))
diff --git a/opentech/apply/projects/tests/test_views.py b/opentech/apply/projects/tests/test_views.py
index 511bcdfb0aaa197ac221becc1aaff112b615a1b7..8d84549be4d9b8766df977d36034df4130f61501 100644
--- a/opentech/apply/projects/tests/test_views.py
+++ b/opentech/apply/projects/tests/test_views.py
@@ -1,6 +1,9 @@
+from decimal import Decimal
 from io import BytesIO
 
 from django.contrib.auth.models import AnonymousUser
+from django.core.exceptions import PermissionDenied
+from django.test import RequestFactory, TestCase, override_settings
 from django.urls import reverse
 
 from opentech.apply.funds.tests.factories import LabSubmissionFactory
@@ -10,42 +13,94 @@ from opentech.apply.users.tests.factories import (
     ReviewerFactory,
     StaffFactory,
     SuperUserFactory,
-    UserFactory,
+    UserFactory
 )
 from opentech.apply.utils.testing.tests import BaseViewTestCase
 
 from ..forms import SetPendingForm
+from ..files import get_files
+from ..models import (
+    CHANGES_REQUESTED,
+    COMMITTED,
+    CONTRACTING,
+    IN_PROGRESS,
+    SUBMITTED,
+)
+from ..views import ContractsMixin, ProjectDetailSimplifiedView
 from .factories import (
+    ContractFactory,
     DocumentCategoryFactory,
     PacketFileFactory,
-    ProjectFactory,
+    PaymentReceiptFactory,
+    PaymentRequestFactory,
+    ProjectFactory
 )
 
 
+class TestUpdateLeadView(BaseViewTestCase):
+    base_view_name = 'detail'
+    url_name = 'funds:projects:{}'
+    user_factory = ApproverFactory
+
+    def get_kwargs(self, instance):
+        return {'pk': instance.id}
+
+    def test_update_lead(self):
+        project = ProjectFactory()
+
+        new_lead = self.user_factory()
+        response = self.post_page(project, {'form-submitted-lead_form': '', 'lead': new_lead.id})
+        self.assertEqual(response.status_code, 200)
+
+        project.refresh_from_db()
+        self.assertEqual(project.lead, new_lead)
+
+    def test_update_lead_from_none(self):
+        project = ProjectFactory(lead=None)
+
+        new_lead = self.user_factory()
+        response = self.post_page(project, {'form-submitted-lead_form': '', 'lead': new_lead.id})
+        self.assertEqual(response.status_code, 200)
+
+        project.refresh_from_db()
+        self.assertEqual(project.lead, new_lead)
+
+
 class TestCreateApprovalView(BaseViewTestCase):
     base_view_name = 'detail'
     url_name = 'funds:projects:{}'
-    user_factory = StaffFactory
+    user_factory = ApproverFactory
 
     def get_kwargs(self, instance):
         return {'pk': instance.id}
 
     def test_creating_an_approval_happy_path(self):
-        project = ProjectFactory()
+        project = ProjectFactory(in_approval=True)
         self.assertEqual(project.approvals.count(), 0)
 
         response = self.post_page(project, {'form-submitted-add_approval_form': '', 'by': self.user.id})
         self.assertEqual(response.status_code, 200)
 
         project.refresh_from_db()
-        approval = project.approvals.first()
-
         self.assertEqual(project.approvals.count(), 1)
         self.assertFalse(project.is_locked)
         self.assertEqual(project.status, 'contracting')
 
+        approval = project.approvals.first()
         self.assertEqual(approval.project_id, project.pk)
 
+    def test_creating_an_approval_other_approver(self):
+        project = ProjectFactory(in_approval=True)
+        self.assertEqual(project.approvals.count(), 0)
+
+        other = self.user_factory()
+        response = self.post_page(project, {'form-submitted-add_approval_form': '', 'by': other.id})
+        self.assertEqual(response.status_code, 200)
+
+        project.refresh_from_db()
+        self.assertEqual(project.approvals.count(), 0)
+        self.assertTrue(project.is_locked)
+
 
 class BaseProjectDetailTestCase(BaseViewTestCase):
     url_name = 'funds:projects:{}'
@@ -70,7 +125,7 @@ class TestStaffProjectDetailView(BaseProjectDetailTestCase):
 
 
 class TestUserProjectDetailView(BaseProjectDetailTestCase):
-    user_factory = UserFactory
+    user_factory = ApplicantFactory
 
     def test_doesnt_have_access(self):
         project = ProjectFactory()
@@ -101,6 +156,62 @@ class TestReviewerUserProjectDetailView(BaseProjectDetailTestCase):
         self.assertEqual(response.status_code, 403)
 
 
+class TestStaffProjectRejectView(BaseProjectDetailTestCase):
+    user_factory = StaffFactory
+
+    def test_cant_reject(self):
+        project = ProjectFactory(in_approval=True)
+        response = self.post_page(project, {
+            'form-submitted-rejection_form': '',
+            'comment': 'needs to change',
+        })
+        self.assertEqual(response.status_code, 403)
+        project = self.refresh(project)
+        self.assertEqual(project.status, COMMITTED)
+        self.assertTrue(project.is_locked)
+
+
+class TestApproverProjectRejectView(BaseProjectDetailTestCase):
+    user_factory = ApproverFactory
+
+    def test_can_reject(self):
+        project = ProjectFactory(in_approval=True)
+        response = self.post_page(project, {
+            'form-submitted-rejection_form': '',
+            'comment': 'needs to change',
+        })
+        self.assertEqual(response.status_code, 200)
+        project = self.refresh(project)
+        self.assertEqual(project.status, COMMITTED)
+        self.assertFalse(project.is_locked)
+
+    def test_cant_reject_no_comment(self):
+        project = ProjectFactory(in_approval=True)
+        response = self.post_page(project, {
+            'form-submitted-rejection_form': '',
+            'comment': '',
+        })
+        self.assertEqual(response.status_code, 200)
+        project = self.refresh(project)
+        self.assertEqual(project.status, COMMITTED)
+        self.assertTrue(project.is_locked)
+
+
+class TestUserProjectRejectView(BaseProjectDetailTestCase):
+    user_factory = ApplicantFactory
+
+    def test_cant_reject(self):
+        project = ProjectFactory(in_approval=True, user=self.user)
+        response = self.post_page(project, {
+            'form-submitted-rejection_form': '',
+            'comment': 'needs to change',
+        })
+        self.assertEqual(response.status_code, 200)
+        project = self.refresh(project)
+        self.assertEqual(project.status, COMMITTED)
+        self.assertTrue(project.is_locked)
+
+
 class TestRemoveDocumentView(BaseViewTestCase):
     base_view_name = 'detail'
     url_name = 'funds:projects:{}'
@@ -166,6 +277,144 @@ class TestSendForApprovalView(BaseViewTestCase):
         self.assertEqual(project.status, 'committed')
 
 
+class TestApplicantUploadContractView(BaseViewTestCase):
+    base_view_name = 'detail'
+    url_name = 'funds:projects:{}'
+    user_factory = ApplicantFactory
+
+    def get_kwargs(self, instance):
+        return {'pk': instance.id}
+
+    def test_owner_upload_contract(self):
+        project = ProjectFactory(user=self.user)
+
+        test_doc = BytesIO(b'somebinarydata')
+        test_doc.name = 'contract.pdf'
+
+        response = self.post_page(project, {
+            'form-submitted-contract_form': '',
+            'file': test_doc,
+        })
+        self.assertEqual(response.status_code, 200)
+
+        project.refresh_from_db()
+
+        self.assertEqual(project.contracts.count(), 1)
+        self.assertTrue(project.contracts.first().is_signed)
+
+    def test_non_owner_upload_contract(self):
+        project = ProjectFactory()
+        contract_count = project.contracts.count()
+
+        test_doc = BytesIO(b'somebinarydata')
+        test_doc.name = 'contract.pdf'
+
+        response = self.post_page(project, {
+            'form-submitted-contract_form': '',
+            'file': test_doc,
+        })
+        self.assertEqual(response.status_code, 403)
+
+        project.refresh_from_db()
+        self.assertEqual(project.contracts.count(), contract_count)
+
+
+class TestStaffUploadContractView(BaseViewTestCase):
+    base_view_name = 'detail'
+    url_name = 'funds:projects:{}'
+    user_factory = StaffFactory
+
+    def get_kwargs(self, instance):
+        return {'pk': instance.id}
+
+    def test_upload_contract(self):
+        project = ProjectFactory()
+
+        test_doc = BytesIO(b'somebinarydata')
+        test_doc.name = 'contract.pdf'
+
+        response = self.post_page(project, {
+            'form-submitted-contract_form': '',
+            'file': test_doc,
+        })
+        self.assertEqual(response.status_code, 200)
+
+        project.refresh_from_db()
+
+        self.assertEqual(project.contracts.count(), 1)
+        self.assertFalse(project.contracts.first().is_signed)
+
+    def test_upload_contract_with_signed_set_to_true(self):
+        project = ProjectFactory()
+
+        test_doc = BytesIO(b'somebinarydata')
+        test_doc.name = 'contract.pdf'
+
+        response = self.post_page(project, {
+            'form-submitted-contract_form': '',
+            'file': test_doc,
+            'is_signed': True,
+        })
+        self.assertEqual(response.status_code, 200)
+
+        project.refresh_from_db()
+
+        self.assertEqual(project.contracts.count(), 1)
+        self.assertTrue(project.contracts.first().is_signed)
+
+
+class TestStaffSelectDocumentView(BaseViewTestCase):
+    base_view_name = 'detail'
+    url_name = 'funds:projects:{}'
+    user_factory = StaffFactory
+
+    def get_kwargs(self, instance):
+        return {'pk': instance.id}
+
+    def test_can_choose(self):
+        category = DocumentCategoryFactory()
+        project = ProjectFactory()
+
+        files = get_files(project)
+
+        response = self.post_page(project, {
+            'form-submitted-select_document_form': '',
+            'category': category.id,
+            'document': files[0].url,
+        })
+        self.assertEqual(response.status_code, 200)
+
+        project.refresh_from_db()
+
+        self.assertEqual(project.packet_files.count(), 1)
+
+
+class TestApplicantSelectDocumentView(BaseViewTestCase):
+    base_view_name = 'detail'
+    url_name = 'funds:projects:{}'
+    user_factory = ApplicantFactory
+
+    def get_kwargs(self, instance):
+        return {'pk': instance.id}
+
+    def test_can_choose(self):
+        category = DocumentCategoryFactory()
+        project = ProjectFactory(user=self.user)
+
+        files = get_files(project)
+
+        response = self.post_page(project, {
+            'form-submitted-select_document_form': '',
+            'category': category.id,
+            'document': files[0].url,
+        })
+        self.assertEqual(response.status_code, 200)
+
+        project.refresh_from_db()
+
+        self.assertEqual(project.packet_files.count(), 1)
+
+
 class TestUploadDocumentView(BaseViewTestCase):
     base_view_name = 'detail'
     url_name = 'funds:projects:{}'
@@ -257,6 +506,31 @@ class TestStaffProjectEditView(BaseProjectEditTestCase):
         self.assertEqual(response.status_code, 200)
         self.assertRedirects(response, self.url(project, 'detail'))
 
+    def test_no_paf_form_renders(self):
+        project = ProjectFactory(
+            submission__round__parent__approval_form=None,
+            form_fields=None,
+            form_data={},
+        )
+        response = self.get_page(project)
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.redirect_chain, [])
+
+    def test_pulls_paf_from_fund(self):
+        project = ProjectFactory(form_fields=None, form_data={})
+        response = self.get_page(project)
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.redirect_chain, [])
+
+    def test_edited_form_renders(self):
+        project = ProjectFactory()
+        response = self.get_page(project)
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.redirect_chain, [])
+
 
 class TestApproverProjectEditView(BaseProjectEditTestCase):
     user_factory = ApproverFactory
@@ -291,6 +565,211 @@ class TestReviewerProjectEditView(BaseProjectEditTestCase):
         self.assertEqual(response.redirect_chain, [])
 
 
+class TestContractsMixin(TestCase):
+    class DummyView:
+        def get_context_data(self):
+            return {}
+
+    class DummyContractsView(ContractsMixin, DummyView):
+        def __init__(self, project):
+            self.project = project
+
+        def get_object(self):
+            return self.project
+
+    def test_all_signed_and_approved_contracts_appear(self):
+        project = ProjectFactory()
+        user = StaffFactory()
+        ContractFactory(project=project, is_signed=True, approver=user)
+        ContractFactory(project=project, is_signed=True, approver=user)
+        ContractFactory(project=project, is_signed=True, approver=user)
+
+        contracts = self.DummyContractsView(project).get_context_data()['contracts']
+
+        self.assertEqual(len(contracts), 3)
+
+    def test_mixture_with_latest_signed_returns_no_unsigned(self):
+        project = ProjectFactory()
+        user = StaffFactory()
+        ContractFactory(project=project, is_signed=True, approver=user)
+        ContractFactory(project=project, is_signed=False, approver=None)
+        ContractFactory(project=project, is_signed=True, approver=user)
+
+        contracts = self.DummyContractsView(project).get_context_data()['contracts']
+
+        self.assertEqual(len(contracts), 2)
+        for contract in contracts:
+            self.assertTrue(contract.is_signed)
+
+    def test_no_contracts_returns_nothing(self):
+        project = ProjectFactory()
+        contracts = self.DummyContractsView(project).get_context_data()['contracts']
+
+        self.assertEqual(len(contracts), 0)
+
+    def test_all_unsigned_and_unapproved_returns_only_latest(self):
+        project = ProjectFactory()
+        ContractFactory(project=project, is_signed=False, approver=None)
+        ContractFactory(project=project, is_signed=False, approver=None)
+        latest = ContractFactory(project=project, is_signed=False, approver=None)
+
+        context = self.DummyContractsView(project).get_context_data()
+
+        contracts = context['contracts']
+        to_approve = context['contract_to_approve']
+        to_sign = context['contract_to_sign']
+
+        self.assertEqual(len(contracts), 0)
+        self.assertEqual(latest, to_sign)
+        self.assertIsNone(to_approve)
+
+    def test_all_signed_and_unapproved_returns_latest(self):
+        project = ProjectFactory()
+        ContractFactory(project=project, is_signed=True, approver=None)
+        ContractFactory(project=project, is_signed=True, approver=None)
+        latest = ContractFactory(project=project, is_signed=True, approver=None)
+
+        context = self.DummyContractsView(project).get_context_data()
+
+        contracts = context['contracts']
+        to_approve = context['contract_to_approve']
+        to_sign = context['contract_to_sign']
+
+        self.assertEqual(len(contracts), 0)
+        self.assertEqual(latest, to_approve)
+        self.assertIsNone(to_sign)
+
+    def test_mixture_of_both_latest_unsigned_and_unapproved(self):
+        project = ProjectFactory()
+        user = StaffFactory()
+        ContractFactory(project=project, is_signed=True, approver=None)
+        ContractFactory(project=project, is_signed=True, approver=user)
+        ContractFactory(project=project, is_signed=False, approver=None)
+        ContractFactory(project=project, is_signed=True, approver=user)
+        latest = ContractFactory(project=project, is_signed=False, approver=None)
+
+        context = self.DummyContractsView(project).get_context_data()
+
+        contracts = context['contracts']
+        to_approve = context['contract_to_approve']
+        to_sign = context['contract_to_sign']
+
+        self.assertEqual(len(contracts), 2)
+        self.assertEqual(latest, to_sign)
+        self.assertIsNone(to_approve)
+
+    def test_mixture_of_both_latest_signed_and_unapproved(self):
+        project = ProjectFactory()
+        user = StaffFactory()
+        ContractFactory(project=project, is_signed=True, approver=None)
+        ContractFactory(project=project, is_signed=True, approver=user)
+        ContractFactory(project=project, is_signed=False, approver=None)
+        ContractFactory(project=project, is_signed=True, approver=user)
+        latest = ContractFactory(project=project, is_signed=True, approver=None)
+
+        context = self.DummyContractsView(project).get_context_data()
+
+        contracts = context['contracts']
+        to_approve = context['contract_to_approve']
+        to_sign = context['contract_to_sign']
+
+        self.assertEqual(len(contracts), 2)
+        self.assertEqual(latest, to_approve)
+        self.assertIsNone(to_sign)
+
+    def test_mixture_of_both_latest_signed_and_approved(self):
+        project = ProjectFactory()
+        user = StaffFactory()
+        ContractFactory(project=project, is_signed=True, approver=None)
+        ContractFactory(project=project, is_signed=True, approver=user)
+        ContractFactory(project=project, is_signed=False, approver=None)
+        ContractFactory(project=project, is_signed=True, approver=user)
+        ContractFactory(project=project, is_signed=True, approver=user)
+
+        context = self.DummyContractsView(project).get_context_data()
+
+        contracts = context['contracts']
+        to_approve = context['contract_to_approve']
+        to_sign = context['contract_to_sign']
+
+        self.assertEqual(len(contracts), 3)
+        self.assertIsNone(to_approve)
+        self.assertIsNone(to_sign)
+
+
+class TestApproveContractView(BaseViewTestCase):
+    base_view_name = 'detail'
+    url_name = 'funds:projects:{}'
+    user_factory = StaffFactory
+
+    def get_kwargs(self, instance):
+        return {'pk': instance.id}
+
+    def test_approve_unapproved_contract(self):
+        project = ProjectFactory(status=CONTRACTING)
+        contract = ContractFactory(project=project, is_signed=True, approver=None)
+
+        response = self.post_page(project, {
+            'form-submitted-approve_contract_form': '',
+            'id': contract.id,
+        })
+        self.assertEqual(response.status_code, 200)
+
+        contract.refresh_from_db()
+        self.assertEqual(contract.approver, self.user)
+
+        project.refresh_from_db()
+        self.assertEqual(project.status, IN_PROGRESS)
+
+    def test_approve_already_approved_contract(self):
+        project = ProjectFactory(status=IN_PROGRESS)
+        user = StaffFactory()
+        contract = ContractFactory(project=project, is_signed=True, approver=user)
+
+        response = self.post_page(project, {
+            'form-submitted-approve_contract_form': '',
+            'id': contract.id,
+        })
+        self.assertEqual(response.status_code, 200)
+
+        contract.refresh_from_db()
+        self.assertEqual(contract.approver, user)
+
+        project.refresh_from_db()
+        self.assertEqual(project.status, IN_PROGRESS)
+
+    def test_approve_unsigned_contract(self):
+        project = ProjectFactory()
+        contract = ContractFactory(project=project, is_signed=False, approver=None)
+
+        response = self.post_page(project, {
+            'form-submitted-approve_contract_form': '',
+            'id': contract.id,
+        })
+        self.assertEqual(response.status_code, 200)
+
+        messages = list(response.context['messages'])
+        self.assertEqual(len(messages), 1)
+
+    def test_attempt_to_approve_non_latest(self):
+        project = ProjectFactory()
+        contract_attempt = ContractFactory(project=project, is_signed=True, approver=None)
+        contract_meant = ContractFactory(project=project, is_signed=True, approver=None)
+
+        response = self.post_page(project, {
+            'form-submitted-approve_contract_form': '',
+            'id': contract_attempt.id,
+        })
+        self.assertEqual(response.status_code, 200)
+
+        messages = list(response.context['messages'])
+        self.assertEqual(len(messages), 1)
+        contract_attempt.refresh_from_db()
+        contract_meant.refresh_from_db()
+        self.assertIsNone(contract_attempt.approver)
+        self.assertIsNone(contract_meant.approver)
+
+
 class BasePacketFileViewTestCase(BaseViewTestCase):
     url_name = 'funds:projects:{}'
     base_view_name = 'document'
@@ -338,3 +817,420 @@ class TestAnonPacketView(BasePacketFileViewTestCase):
         self.assertEqual(len(response.redirect_chain), 2)
         for path, _ in response.redirect_chain:
             self.assertIn(reverse('users_public:login'), path)
+
+
+class TestRequestPaymentViewAsApplicant(BaseViewTestCase):
+    base_view_name = 'request'
+    url_name = 'funds:projects:{}'
+    user_factory = ApplicantFactory
+
+    def get_kwargs(self, instance):
+        return {'pk': instance.id}
+
+    def test_creating_a_payment_request(self):
+        project = ProjectFactory(user=self.user)
+        self.assertEqual(project.payment_requests.count(), 0)
+
+        invoice = BytesIO(b'somebinarydata')
+        invoice.name = 'invoice.pdf'
+
+        receipts = BytesIO(b'someotherbinarydata')
+        receipts.name = 'receipts.pdf'
+
+        response = self.post_page(project, {
+            'form-submitted-request_payment_form': '',
+            'requested_value': '10',
+            'date_from': '2018-08-15',
+            'date_to': '2019-08-15',
+            'invoice': invoice,
+            'receipts': receipts,
+        })
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(project.payment_requests.count(), 1)
+
+        self.assertEqual(project.payment_requests.first().by, self.user)
+
+
+class TestRequestPaymentViewAsStaff(BaseViewTestCase):
+    base_view_name = 'request'
+    url_name = 'funds:projects:{}'
+    user_factory = StaffFactory
+
+    def get_kwargs(self, instance):
+        return {'pk': instance.id}
+
+    def test_creating_a_payment_request(self):
+        project = ProjectFactory()
+        self.assertEqual(project.payment_requests.count(), 0)
+
+        invoice = BytesIO(b'somebinarydata')
+        invoice.name = 'invoice.pdf'
+
+        receipts = BytesIO(b'someotherbinarydata')
+        receipts.name = 'receipts.pdf'
+
+        response = self.post_page(project, {
+            'form-submitted-request_payment_form': '',
+            'requested_value': '10',
+            'date_from': '2018-08-15',
+            'date_to': '2019-08-15',
+            'comment': 'test comment',
+            'invoice': invoice,
+            'receipts': receipts,
+        })
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(project.payment_requests.count(), 1)
+
+        self.assertEqual(project.payment_requests.first().by, self.user)
+
+
+class TestProjectDetailSimplifiedView(TestCase):
+    def test_staff_only(self):
+        factory = RequestFactory()
+        project = ProjectFactory()
+
+        request = factory.get(f'/project/{project.pk}')
+        request.user = StaffFactory()
+
+        response = ProjectDetailSimplifiedView.as_view()(request, pk=project.pk)
+        self.assertEqual(response.status_code, 200)
+
+        request.user = ApplicantFactory()
+        with self.assertRaises(PermissionDenied):
+            ProjectDetailSimplifiedView.as_view()(request, pk=project.pk)
+
+
+class TestStaffDetailPaymentRequestStatus(BaseViewTestCase):
+    base_view_name = 'detail'
+    url_name = 'funds:projects:payments:{}'
+    user_factory = StaffFactory
+
+    def get_kwargs(self, instance):
+        return {
+            'pk': instance.pk,
+        }
+
+    def test_can(self):
+        payment_request = PaymentRequestFactory()
+        response = self.get_page(payment_request)
+        self.assertEqual(response.status_code, 200)
+
+    def test_wrong_project_cant(self):
+        other_project = ProjectFactory()
+        payment_request = PaymentRequestFactory()
+        response = self.get_page(payment_request, url_kwargs={'pk': other_project.pk})
+        self.assertEqual(response.status_code, 404)
+
+
+class TestApplicantDetailPaymentRequestStatus(BaseViewTestCase):
+    base_view_name = 'detail'
+    url_name = 'funds:projects:payments:{}'
+    user_factory = ApplicantFactory
+
+    def get_kwargs(self, instance):
+        return {
+            'pk': instance.pk,
+        }
+
+    def test_can(self):
+        payment_request = PaymentRequestFactory(project__user=self.user)
+        response = self.get_page(payment_request)
+        self.assertEqual(response.status_code, 200)
+
+    def test_other_cant(self):
+        payment_request = PaymentRequestFactory()
+        response = self.get_page(payment_request)
+        self.assertEqual(response.status_code, 403)
+
+
+class TestApplicantEditPaymentRequestView(BaseViewTestCase):
+    base_view_name = 'edit'
+    url_name = 'funds:projects:payments:{}'
+    user_factory = ApplicantFactory
+
+    def get_kwargs(self, instance):
+        return {'pk': instance.pk}
+
+    def test_editing_payment_remove_receipt(self):
+        payment_request = PaymentRequestFactory(project__user=self.user)
+        receipt = PaymentReceiptFactory(payment_request=payment_request)
+
+        response = self.post_page(payment_request, {
+            'requested_value': payment_request.requested_value,
+            'date_from': '2018-08-15',
+            'date_to': '2019-08-15',
+            'comment': 'test comment',
+            'invoice': None,
+            'receipt_list': [receipt.pk],
+        })
+
+        self.assertEqual(response.status_code, 200)
+
+        self.assertFalse(payment_request.receipts.exists())
+
+    def test_editing_payment_keeps_receipts(self):
+        project = ProjectFactory(user=self.user)
+        payment_request = PaymentRequestFactory(project=project)
+        receipt = PaymentReceiptFactory(payment_request=payment_request)
+
+        requested_value = payment_request.requested_value
+
+        response = self.post_page(payment_request, {
+            'requested_value': requested_value + 1,
+            'date_from': '2018-08-15',
+            'date_to': '2019-08-15',
+            'comment': 'test comment',
+            'invoice': None,
+            'receipt_list': [],
+        })
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(project.payment_requests.count(), 1)
+
+        payment_request.refresh_from_db()
+
+        self.assertEqual(project.payment_requests.first().pk, payment_request.pk)
+
+        self.assertEqual(requested_value + Decimal("1"), payment_request.requested_value)
+        self.assertEqual(payment_request.receipts.first().file, receipt.file)
+
+
+class TestStaffEditPaymentRequestView(BaseViewTestCase):
+    base_view_name = 'edit'
+    url_name = 'funds:projects:payments:{}'
+    user_factory = StaffFactory
+
+    def get_kwargs(self, instance):
+        return {'pk': instance.pk}
+
+    def test_editing_payment_remove_receipt(self):
+        payment_request = PaymentRequestFactory()
+        receipt = PaymentReceiptFactory(payment_request=payment_request)
+
+        response = self.post_page(payment_request, {
+            'requested_value': payment_request.requested_value,
+            'date_from': '2018-08-15',
+            'date_to': '2019-08-15',
+            'comment': 'test comment',
+            'invoice': None,
+            'receipt_list': [receipt.pk],
+        })
+
+        self.assertEqual(response.status_code, 200)
+
+        self.assertFalse(payment_request.receipts.exists())
+
+    def test_editing_payment_keeps_receipts(self):
+        project = ProjectFactory()
+        payment_request = PaymentRequestFactory(project=project)
+        receipt = PaymentReceiptFactory(payment_request=payment_request)
+
+        requested_value = payment_request.requested_value
+
+        invoice = BytesIO(b'somebinarydata')
+        invoice.name = 'invoice.pdf'
+
+        response = self.post_page(payment_request, {
+            'requested_value': requested_value + 1,
+            'date_from': '2018-08-15',
+            'date_to': '2019-08-15',
+            'comment': 'test comment',
+            'invoice': invoice,
+            'receipt_list': [receipt.pk],
+        })
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(project.payment_requests.count(), 1)
+
+        payment_request.refresh_from_db()
+
+        self.assertEqual(project.payment_requests.first().pk, payment_request.pk)
+
+        self.assertEqual(requested_value + Decimal("1"), payment_request.requested_value)
+
+
+class TestStaffChangePaymentRequestStatus(BaseViewTestCase):
+    base_view_name = 'detail'
+    url_name = 'funds:projects:payments:{}'
+    user_factory = StaffFactory
+
+    def get_kwargs(self, instance):
+        return {
+            'pk': instance.pk,
+        }
+
+    def test_can(self):
+        payment_request = PaymentRequestFactory()
+        response = self.post_page(payment_request, {
+            'form-submitted-change_payment_status': '',
+            'status': CHANGES_REQUESTED,
+            'comment': 'this is a comment',
+        })
+        self.assertEqual(response.status_code, 200)
+        payment_request.refresh_from_db()
+        self.assertEqual(payment_request.status, CHANGES_REQUESTED)
+
+
+class TestApplicantChangePaymentRequestStatus(BaseViewTestCase):
+    base_view_name = 'detail'
+    url_name = 'funds:projects:payments:{}'
+    user_factory = ApplicantFactory
+
+    def get_kwargs(self, instance):
+        return {
+            'pk': instance.pk,
+        }
+
+    def test_can(self):
+        payment_request = PaymentRequestFactory(project__user=self.user)
+        response = self.post_page(payment_request, {
+            'form-submitted-change_payment_status': '',
+            'status': CHANGES_REQUESTED,
+        })
+        self.assertEqual(response.status_code, 200)
+        payment_request.refresh_from_db()
+        self.assertEqual(payment_request.status, SUBMITTED)
+
+    def test_other_cant(self):
+        payment_request = PaymentRequestFactory()
+        response = self.post_page(payment_request, {
+            'form-submitted-change_payment_status': '',
+            'status': CHANGES_REQUESTED,
+        })
+        self.assertEqual(response.status_code, 403)
+        payment_request.refresh_from_db()
+        self.assertEqual(payment_request.status, SUBMITTED)
+
+
+class TestStaffPaymentRequestInvoicePrivateMedia(BaseViewTestCase):
+    base_view_name = 'invoice'
+    url_name = 'funds:projects:payments:{}'
+    user_factory = StaffFactory
+
+    def get_kwargs(self, instance):
+        return {
+            'pk': instance.pk,
+        }
+
+    def test_can_access(self):
+        payment_request = PaymentRequestFactory()
+        response = self.get_page(payment_request)
+        self.assertContains(response, payment_request.invoice.read())
+
+    def test_cant_access_if_project_wrong(self):
+        other_project = ProjectFactory()
+        payment_request = PaymentRequestFactory()
+        response = self.get_page(payment_request, url_kwargs={'pk': other_project.pk})
+        self.assertEqual(response.status_code, 404)
+
+
+class TestApplicantPaymentRequestInvoicePrivateMedia(BaseViewTestCase):
+    base_view_name = 'invoice'
+    url_name = 'funds:projects:payments:{}'
+    user_factory = ApplicantFactory
+
+    def get_kwargs(self, instance):
+        return {
+            'pk': instance.pk,
+        }
+
+    def test_can_access_own(self):
+        payment_request = PaymentRequestFactory(project__user=self.user)
+        response = self.get_page(payment_request)
+        self.assertContains(response, payment_request.invoice.read())
+
+    def test_cant_access_other(self):
+        payment_request = PaymentRequestFactory()
+        response = self.get_page(payment_request)
+        self.assertEqual(response.status_code, 403)
+
+
+class TestStaffPaymentRequestReceiptPrivateMedia(BaseViewTestCase):
+    base_view_name = 'receipt'
+    url_name = 'funds:projects:payments:{}'
+    user_factory = StaffFactory
+
+    def get_kwargs(self, instance):
+        return {
+            'pk': instance.payment_request.pk,
+            'file_pk': instance.pk,
+        }
+
+    def test_can_access(self):
+        payment_receipt = PaymentReceiptFactory()
+        response = self.get_page(payment_receipt)
+        self.assertContains(response, payment_receipt.file.read())
+
+
+class TestApplicantPaymentRequestReceiptPrivateMedia(BaseViewTestCase):
+    base_view_name = 'receipt'
+    url_name = 'funds:projects:payments:{}'
+    user_factory = ApplicantFactory
+
+    def get_kwargs(self, instance):
+        return {
+            'pk': instance.payment_request.pk,
+            'file_pk': instance.pk,
+        }
+
+    def test_can_access_own(self):
+        payment_receipt = PaymentReceiptFactory(payment_request__project__user=self.user)
+        response = self.get_page(payment_receipt)
+        self.assertContains(response, payment_receipt.file.read())
+
+    def test_cant_access_other(self):
+        payment_receipt = PaymentReceiptFactory()
+        response = self.get_page(payment_receipt)
+        self.assertEqual(response.status_code, 403)
+
+
+@override_settings(ROOT_URLCONF='opentech.apply.urls')
+class TestProjectListView(TestCase):
+    def test_staff_can_access_project_list_page(self):
+        ProjectFactory(status=CONTRACTING)
+        ProjectFactory(status=IN_PROGRESS)
+
+        self.client.force_login(StaffFactory())
+
+        url = reverse('apply:projects:all')
+
+        response = self.client.get(url, follow=True)
+        self.assertEqual(response.status_code, 200)
+
+    def test_applicants_cannot_access_project_list_page(self):
+        ProjectFactory(status=CONTRACTING)
+        ProjectFactory(status=IN_PROGRESS)
+
+        self.client.force_login(UserFactory())
+
+        url = reverse('apply:projects:all')
+
+        response = self.client.get(url, follow=True)
+        self.assertEqual(response.status_code, 403)
+
+
+@override_settings(ROOT_URLCONF='opentech.apply.urls')
+class TestProjectOverviewView(TestCase):
+    def test_staff_can_access(self):
+        ProjectFactory(status=CONTRACTING)
+        ProjectFactory(status=IN_PROGRESS)
+
+        self.client.force_login(StaffFactory())
+
+        url = reverse('apply:projects:overview')
+
+        response = self.client.get(url, follow=True)
+        self.assertEqual(response.status_code, 200)
+
+    def test_applicants_cannot_access(self):
+        ProjectFactory(status=CONTRACTING)
+        ProjectFactory(status=IN_PROGRESS)
+
+        self.client.force_login(UserFactory())
+
+        url = reverse('apply:projects:overview')
+
+        response = self.client.get(url, follow=True)
+        self.assertEqual(response.status_code, 403)
diff --git a/opentech/apply/projects/urls.py b/opentech/apply/projects/urls.py
index f3ec5b4485357925701ee72aa5084d631c8ceda4..81c86378d4ed4add4301b5bb8fb535df20c4e2e5 100644
--- a/opentech/apply/projects/urls.py
+++ b/opentech/apply/projects/urls.py
@@ -1,7 +1,21 @@
 from django.conf import settings
 from django.urls import include, path
 
-from .views import ProjectDetailView, ProjectEditView, ProjectPrivateMediaView
+from .views import (
+    ContractPrivateMediaView,
+    CreatePaymentRequestView,
+    DeletePaymentRequestView,
+    EditPaymentRequestView,
+    PaymentRequestListView,
+    PaymentRequestPrivateMedia,
+    PaymentRequestView,
+    ProjectDetailSimplifiedView,
+    ProjectDetailView,
+    ProjectEditView,
+    ProjectListView,
+    ProjectOverviewView,
+    ProjectPrivateMediaView,
+)
 
 app_name = 'projects'
 
@@ -9,9 +23,24 @@ urlpatterns = []
 
 if settings.PROJECTS_ENABLED:
     urlpatterns = [
+        path('', ProjectOverviewView.as_view(), name='overview'),
+        path('all/', ProjectListView.as_view(), name='all'),
         path('<int:pk>/', include([
             path('', ProjectDetailView.as_view(), name='detail'),
             path('edit/', ProjectEditView.as_view(), name="edit"),
             path('documents/<int:file_pk>/', ProjectPrivateMediaView.as_view(), name="document"),
+            path('contract/<int:file_pk>/', ContractPrivateMediaView.as_view(), name="contract"),
+            path('simplified/', ProjectDetailSimplifiedView.as_view(), name='simplified'),
+            path('request/', CreatePaymentRequestView.as_view(), name='request'),
         ])),
+        path('payment-requests/', include(([
+            path('', PaymentRequestListView.as_view(), name='all'),
+            path('<int:pk>/', include([
+                path('', PaymentRequestView.as_view(), name='detail'),
+                path('edit/', EditPaymentRequestView.as_view(), name='edit'),
+                path('delete/', DeletePaymentRequestView.as_view(), name='delete'),
+                path('documents/invoice/', PaymentRequestPrivateMedia.as_view(), name="invoice"),
+                path('documents/receipt/<int:file_pk>/', PaymentRequestPrivateMedia.as_view(), name="receipt"),
+            ])),
+        ], 'payments'))),
     ]
diff --git a/opentech/apply/projects/views.py b/opentech/apply/projects/views.py
deleted file mode 100644
index 812fee430e921d6fcb3e7ad812bad6a531000799..0000000000000000000000000000000000000000
--- a/opentech/apply/projects/views.py
+++ /dev/null
@@ -1,261 +0,0 @@
-from copy import copy
-
-from django.contrib import messages
-from django.contrib.auth.decorators import login_required
-from django.contrib.auth.mixins import UserPassesTestMixin
-from django.core.exceptions import PermissionDenied
-from django.db import transaction
-from django.http import Http404
-from django.shortcuts import get_object_or_404, redirect
-from django.utils.decorators import method_decorator
-from django.utils.translation import ugettext_lazy as _
-from django.views.generic import CreateView, DetailView, FormView, UpdateView
-
-from opentech.apply.activity.messaging import MESSAGES, messenger
-from opentech.apply.activity.views import ActivityContextMixin, CommentFormView
-from opentech.apply.users.decorators import staff_required
-from opentech.apply.utils.storage import PrivateMediaView
-from opentech.apply.utils.views import (DelegateableView, DelegatedViewMixin,
-                                        ViewDispatcher)
-
-from .forms import (
-    CreateApprovalForm,
-    ProjectApprovalForm,
-    ProjectEditForm,
-    RejectionForm,
-    RemoveDocumentForm,
-    SetPendingForm,
-    UpdateProjectLeadForm,
-    UploadDocumentForm,
-)
-from .models import CONTRACTING, Approval, Project, PacketFile
-
-
-@method_decorator(staff_required, name='dispatch')
-class CreateApprovalView(DelegatedViewMixin, CreateView):
-    context_name = 'add_approval_form'
-    form_class = CreateApprovalForm
-    model = Approval
-
-    @transaction.atomic()
-    def form_valid(self, form):
-        project = self.kwargs['object']
-        form.instance.project = project
-        response = super().form_valid(form)
-
-        messenger(
-            MESSAGES.APPROVE_PROJECT,
-            request=self.request,
-            user=self.request.user,
-            source=project,
-        )
-
-        project.is_locked = False
-        project.status = CONTRACTING
-        project.save(update_fields=['is_locked', 'status'])
-
-        return response
-
-
-@method_decorator(staff_required, name='dispatch')
-class RejectionView(DelegatedViewMixin, UpdateView):
-    context_name = 'rejection_form'
-    form_class = RejectionForm
-    model = Project
-
-    def form_valid(self, form):
-        messenger(
-            MESSAGES.REQUEST_PROJECT_CHANGE,
-            request=self.request,
-            user=self.request.user,
-            source=self.object,
-            comment=form.cleaned_data['comment'],
-        )
-
-        self.object.is_locked = False
-        self.object.save(update_fields=['is_locked'])
-
-        return redirect(self.object)
-
-
-@method_decorator(staff_required, name='dispatch')
-class RemoveDocumentView(DelegatedViewMixin, FormView):
-    context_name = 'remove_document_form'
-    form_class = RemoveDocumentForm
-    model = Project
-
-    def form_valid(self, form):
-        document_id = form.cleaned_data["id"]
-        project = self.kwargs['object']
-
-        try:
-            project.packet_files.get(pk=document_id).delete()
-        except PacketFile.DoesNotExist:
-            pass
-
-        return redirect(project)
-
-
-@method_decorator(staff_required, name='dispatch')
-class SendForApprovalView(DelegatedViewMixin, UpdateView):
-    context_name = 'request_approval_form'
-    form_class = SetPendingForm
-    model = Project
-
-    def form_valid(self, form):
-        # lock project
-        response = super().form_valid(form)
-
-        messenger(
-            MESSAGES.SEND_FOR_APPROVAL,
-            request=self.request,
-            user=self.request.user,
-            source=self.object,
-        )
-
-        return response
-
-
-@method_decorator(staff_required, name='dispatch')
-class UpdateLeadView(DelegatedViewMixin, UpdateView):
-    model = Project
-    form_class = UpdateProjectLeadForm
-    context_name = 'lead_form'
-
-    def form_valid(self, form):
-        # Fetch the old lead from the database
-        old = copy(self.get_object())
-
-        response = super().form_valid(form)
-
-        messenger(
-            MESSAGES.UPDATE_PROJECT_LEAD,
-            request=self.request,
-            user=self.request.user,
-            source=form.instance,
-            related=old.lead or 'Unassigned',
-        )
-
-        return response
-
-
-@method_decorator(staff_required, name='dispatch')
-class UploadDocumentView(DelegatedViewMixin, CreateView):
-    context_name = 'document_form'
-    form_class = UploadDocumentForm
-    model = Project
-
-    def form_valid(self, form):
-        project = self.kwargs['object']
-        form.instance.project = project
-        response = super().form_valid(form)
-
-        messenger(
-            MESSAGES.UPLOAD_DOCUMENT,
-            request=self.request,
-            user=self.request.user,
-            source=project,
-            title=form.instance.title
-        )
-
-        return response
-
-
-class AdminProjectDetailView(ActivityContextMixin, DelegateableView, DetailView):
-    form_views = [
-        CommentFormView,
-        CreateApprovalView,
-        RejectionView,
-        RemoveDocumentView,
-        SendForApprovalView,
-        UpdateLeadView,
-        UploadDocumentView,
-    ]
-    model = Project
-    template_name_suffix = '_admin_detail'
-
-    def get_context_data(self, **kwargs):
-        context = super().get_context_data(**kwargs)
-        context['approvals'] = self.object.approvals.distinct('by')
-        context['remaining_document_categories'] = list(self.object.get_missing_document_categories())
-        return context
-
-
-class ApplicantProjectDetailView(ActivityContextMixin, DelegateableView, DetailView):
-    form_views = [
-        CommentFormView,
-    ]
-
-    model = Project
-    template_name_suffix = '_applicant_detail'
-
-    def dispatch(self, request, *args, **kwargs):
-        project = self.get_object()
-        # This view is only for applicants.
-        if project.user != request.user:
-            raise PermissionDenied
-        return super().dispatch(request, *args, **kwargs)
-
-
-@method_decorator(login_required, name='dispatch')
-class ProjectPrivateMediaView(UserPassesTestMixin, PrivateMediaView):
-    raise_exception = True
-
-    def dispatch(self, *args, **kwargs):
-        project_pk = self.kwargs['pk']
-        self.project = get_object_or_404(Project, pk=project_pk)
-        return super().dispatch(*args, **kwargs)
-
-    def get_media(self, *args, **kwargs):
-        document = PacketFile.objects.get(pk=kwargs['file_pk'])
-        if document.project != self.project:
-            raise Http404
-        return document.document
-
-    def test_func(self):
-        if self.request.user.is_apply_staff:
-            return True
-
-        if self.request.user == self.project.user:
-            return True
-
-        return False
-
-
-class ProjectDetailView(ViewDispatcher):
-    admin_view = AdminProjectDetailView
-    applicant_view = ApplicantProjectDetailView
-
-
-class ProjectApprovalEditView(UpdateView):
-    form_class = ProjectApprovalForm
-    model = Project
-
-    def dispatch(self, request, *args, **kwargs):
-        project = self.get_object()
-        if not project.editable_by(request.user):
-            messages.info(self.request, _('You are not allowed to edit the project at this time'))
-            return redirect(project)
-        return super().dispatch(request, *args, **kwargs)
-
-
-class ApplicantProjectEditView(UpdateView):
-    form_class = ProjectEditForm
-    model = Project
-
-    def dispatch(self, request, *args, **kwargs):
-        project = self.get_object()
-        # This view is only for applicants.
-        if project.user != request.user:
-            raise PermissionDenied
-
-        if not project.editable_by(request.user):
-            messages.info(self.request, _('You are not allowed to edit the project at this time'))
-            return redirect(project)
-
-        return super().dispatch(request, *args, **kwargs)
-
-
-class ProjectEditView(ViewDispatcher):
-    admin_view = ProjectApprovalEditView
-    applicant_view = ApplicantProjectEditView
diff --git a/opentech/apply/projects/views/__init__.py b/opentech/apply/projects/views/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..fa45c29c176b0b33d5b01a6e2e926dc9f487fbc7
--- /dev/null
+++ b/opentech/apply/projects/views/__init__.py
@@ -0,0 +1,2 @@
+from .payment import *  # NOQA
+from .project import *  # NOQA
diff --git a/opentech/apply/projects/views/payment.py b/opentech/apply/projects/views/payment.py
new file mode 100644
index 0000000000000000000000000000000000000000..b9306f7bca7273cdd304831858e2976b020c6318
--- /dev/null
+++ b/opentech/apply/projects/views/payment.py
@@ -0,0 +1,208 @@
+from django.contrib.auth.decorators import login_required
+from django.contrib.auth.mixins import UserPassesTestMixin
+from django.core.exceptions import PermissionDenied
+from django.db import transaction
+from django.shortcuts import get_object_or_404, redirect
+from django.utils.decorators import method_decorator
+from django.views.generic import (
+    CreateView,
+    DeleteView,
+    DetailView,
+    UpdateView
+)
+from django_filters.views import FilterView
+from django_tables2 import SingleTableMixin
+
+from opentech.apply.activity.messaging import MESSAGES, messenger
+from opentech.apply.users.decorators import staff_required
+from opentech.apply.utils.storage import PrivateMediaView
+from opentech.apply.utils.views import (
+    DelegateableView,
+    DelegatedViewMixin,
+    ViewDispatcher,
+)
+
+from ..forms import (
+    ChangePaymentRequestStatusForm,
+    CreatePaymentRequestForm,
+    EditPaymentRequestForm,
+)
+from ..filters import PaymentRequestListFilter
+from ..models import (
+    PaymentRequest,
+    Project
+)
+from ..tables import PaymentRequestsListTable
+
+
+@method_decorator(login_required, name='dispatch')
+class PaymentRequestAccessMixin:
+    model = PaymentRequest
+
+    def dispatch(self, request, *args, **kwargs):
+        is_admin = request.user.is_apply_staff
+        is_owner = request.user == self.get_object().project.user
+        if not (is_owner or is_admin):
+            raise PermissionDenied
+
+        return super().dispatch(request, *args, **kwargs)
+
+
+@method_decorator(staff_required, name='dispatch')
+class ChangePaymentRequestStatusView(DelegatedViewMixin, PaymentRequestAccessMixin, UpdateView):
+    form_class = ChangePaymentRequestStatusForm
+    context_name = 'change_payment_status'
+
+    def get_form_kwargs(self):
+        kwargs = super().get_form_kwargs()
+        kwargs.pop('user')
+        return kwargs
+
+    def form_valid(self, form):
+        response = super().form_valid(form)
+
+        messenger(
+            MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS,
+            request=self.request,
+            user=self.request.user,
+            source=self.object.project,
+            related=self.object,
+        )
+
+        return response
+
+
+class DeletePaymentRequestView(DeleteView):
+    model = PaymentRequest
+
+    def dispatch(self, request, *args, **kwargs):
+        self.object = self.get_object()
+        if not self.object.can_user_delete(request.user):
+            raise PermissionDenied
+
+        return super().dispatch(request, *args, **kwargs)
+
+    @transaction.atomic()
+    def delete(self, request, *args, **kwargs):
+        response = super().delete(request, *args, **kwargs)
+
+        messenger(
+            MESSAGES.DELETE_PAYMENT_REQUEST,
+            request=self.request,
+            user=self.request.user,
+            source=self.project,
+            related=self.object,
+        )
+
+        return response
+
+    def get_success_url(self):
+        return self.project.get_absolute_url()
+
+
+class PaymentRequestAdminView(PaymentRequestAccessMixin, DelegateableView, DetailView):
+    form_views = [
+        ChangePaymentRequestStatusView
+    ]
+    template_name_suffix = '_admin_detail'
+
+
+class PaymentRequestApplicantView(PaymentRequestAccessMixin, DelegateableView, DetailView):
+    form_views = []
+
+
+class PaymentRequestView(ViewDispatcher):
+    admin_view = PaymentRequestAdminView
+    applicant_view = PaymentRequestApplicantView
+
+
+class CreatePaymentRequestView(CreateView):
+    model = PaymentRequest
+    form_class = CreatePaymentRequestForm
+
+    def dispatch(self, request, *args, **kwargs):
+        self.project = Project.objects.get(pk=kwargs['pk'])
+        if not request.user.is_apply_staff and not self.project.user == request.user:
+            return redirect(self.project)
+        return super().dispatch(request, *args, **kwargs)
+
+    def get_context_data(self, **kwargs):
+        return super().get_context_data(project=self.project, **kwargs)
+
+    def form_valid(self, form):
+        form.instance.project = self.project
+        form.instance.by = self.request.user
+
+        response = super().form_valid(form)
+
+        messenger(
+            MESSAGES.REQUEST_PAYMENT,
+            request=self.request,
+            user=self.request.user,
+            source=self.project,
+            related=self.object,
+        )
+
+        return response
+
+
+class EditPaymentRequestView(PaymentRequestAccessMixin, UpdateView):
+    form_class = EditPaymentRequestForm
+
+    def dispatch(self, request, *args, **kwargs):
+        payment_request = self.get_object()
+        if not payment_request.can_user_edit(request.user):
+            return redirect(payment_request)
+        return super().dispatch(request, *args, **kwargs)
+
+    def form_valid(self, form):
+        response = super().form_valid(form)
+
+        messenger(
+            MESSAGES.UPDATE_PAYMENT_REQUEST,
+            request=self.request,
+            user=self.request.user,
+            source=self.object.project,
+            related=self.object,
+        )
+
+        return response
+
+
+@method_decorator(login_required, name='dispatch')
+class PaymentRequestPrivateMedia(UserPassesTestMixin, PrivateMediaView):
+    raise_exception = True
+
+    def dispatch(self, *args, **kwargs):
+        payment_pk = self.kwargs['pk']
+        self.payment_request = get_object_or_404(PaymentRequest, pk=payment_pk)
+
+        return super().dispatch(*args, **kwargs)
+
+    def get_media(self, *args, **kwargs):
+        file_pk = kwargs.get('file_pk')
+        if not file_pk:
+            return self.payment_request.invoice
+
+        receipt = get_object_or_404(self.payment_request.receipts, pk=file_pk)
+        return receipt.file
+
+    def test_func(self):
+        if self.request.user.is_apply_staff:
+            return True
+
+        if self.request.user == self.payment_request.project.user:
+            return True
+
+        return False
+
+
+@method_decorator(staff_required, name='dispatch')
+class PaymentRequestListView(SingleTableMixin, FilterView):
+    filterset_class = PaymentRequestListFilter
+    model = PaymentRequest
+    table_class = PaymentRequestsListTable
+    template_name = 'application_projects/payment_request_list.html'
+
+    def get_queryset(self):
+        return PaymentRequest.objects.order_by('date_to')
diff --git a/opentech/apply/projects/views/project.py b/opentech/apply/projects/views/project.py
new file mode 100644
index 0000000000000000000000000000000000000000..88cf33d6b778495279bcb8380fd7741f2b75ef03
--- /dev/null
+++ b/opentech/apply/projects/views/project.py
@@ -0,0 +1,644 @@
+from copy import copy
+
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.contrib.auth.mixins import UserPassesTestMixin
+from django.core.exceptions import PermissionDenied
+from django.db import transaction
+from django.db.models import Count
+from django.http import Http404
+from django.shortcuts import get_object_or_404, redirect
+from django.urls import reverse, reverse_lazy
+from django.utils.decorators import method_decorator
+from django.utils.functional import cached_property
+from django.utils.text import mark_safe
+from django.utils.translation import ugettext_lazy as _
+from django.views.generic import (
+    CreateView,
+    DetailView,
+    FormView,
+    TemplateView,
+    UpdateView
+)
+from django_filters.views import FilterView
+from django_tables2 import SingleTableMixin
+
+from opentech.apply.activity.messaging import MESSAGES, messenger
+from opentech.apply.activity.views import ActivityContextMixin, CommentFormView
+from opentech.apply.users.decorators import approver_required, staff_required
+from opentech.apply.utils.storage import PrivateMediaView
+from opentech.apply.utils.views import (
+    DelegateableView,
+    DelegatedViewMixin,
+    ViewDispatcher,
+)
+
+from ..files import get_files
+from ..filters import (
+    PaymentRequestListFilter,
+    ProjectListFilter,
+)
+from ..forms import (
+    ApproveContractForm,
+    CreateApprovalForm,
+    ProjectApprovalForm,
+    ProjectEditForm,
+    RejectionForm,
+    RemoveDocumentForm,
+    SelectDocumentForm,
+    SetPendingForm,
+    StaffUploadContractForm,
+    UpdateProjectLeadForm,
+    UploadContractForm,
+    UploadDocumentForm
+)
+from ..models import (
+    CONTRACTING,
+    IN_PROGRESS,
+    PROJECT_STATUS_CHOICES,
+    Approval,
+    Contract,
+    PacketFile,
+    PaymentRequest,
+    Project
+)
+from ..tables import (
+    PaymentRequestsListTable,
+    ProjectsListTable
+)
+
+
+# APPROVAL VIEWS
+
+@method_decorator(staff_required, name='dispatch')
+class SendForApprovalView(DelegatedViewMixin, UpdateView):
+    context_name = 'request_approval_form'
+    form_class = SetPendingForm
+    model = Project
+
+    def form_valid(self, form):
+        # lock project
+        response = super().form_valid(form)
+
+        messenger(
+            MESSAGES.SEND_FOR_APPROVAL,
+            request=self.request,
+            user=self.request.user,
+            source=self.object,
+        )
+
+        return response
+
+
+@method_decorator(staff_required, name='dispatch')
+class CreateApprovalView(DelegatedViewMixin, CreateView):
+    context_name = 'add_approval_form'
+    form_class = CreateApprovalForm
+    model = Approval
+
+    def get_form_kwargs(self):
+        kwargs = super().get_form_kwargs()
+        kwargs.pop('instance')
+        kwargs.get('initial', {}).update({'by': kwargs.get('user')})
+        return kwargs
+
+    @transaction.atomic()
+    def form_valid(self, form):
+        project = self.kwargs['object']
+        old_stage = project.get_status_display()
+
+        form.instance.project = project
+
+        response = super().form_valid(form)
+
+        messenger(
+            MESSAGES.APPROVE_PROJECT,
+            request=self.request,
+            user=self.request.user,
+            source=project,
+        )
+
+        project.send_to_compliance(self.request)
+
+        project.is_locked = False
+        project.status = CONTRACTING
+        project.save(update_fields=['is_locked', 'status'])
+
+        messenger(
+            MESSAGES.PROJECT_TRANSITION,
+            request=self.request,
+            user=self.request.user,
+            source=project,
+            related=old_stage,
+        )
+
+        return response
+
+
+@method_decorator(approver_required, name='dispatch')
+class RejectionView(DelegatedViewMixin, UpdateView):
+    context_name = 'rejection_form'
+    form_class = RejectionForm
+    model = Project
+
+    def form_valid(self, form):
+        messenger(
+            MESSAGES.REQUEST_PROJECT_CHANGE,
+            request=self.request,
+            user=self.request.user,
+            source=self.object,
+            comment=form.cleaned_data['comment'],
+        )
+
+        self.object.is_locked = False
+        self.object.save(update_fields=['is_locked'])
+
+        return redirect(self.object)
+
+
+# PROJECT DOCUMENTS
+
+@method_decorator(staff_required, name='dispatch')
+class UploadDocumentView(DelegatedViewMixin, CreateView):
+    context_name = 'document_form'
+    form_class = UploadDocumentForm
+    model = Project
+
+    def form_valid(self, form):
+        project = self.kwargs['object']
+        form.instance.project = project
+        response = super().form_valid(form)
+
+        messenger(
+            MESSAGES.UPLOAD_DOCUMENT,
+            request=self.request,
+            user=self.request.user,
+            source=project,
+        )
+
+        return response
+
+
+@method_decorator(staff_required, name='dispatch')
+class RemoveDocumentView(DelegatedViewMixin, FormView):
+    context_name = 'remove_document_form'
+    form_class = RemoveDocumentForm
+    model = Project
+
+    def form_valid(self, form):
+        document_id = form.cleaned_data["id"]
+        project = self.kwargs['object']
+
+        try:
+            project.packet_files.get(pk=document_id).delete()
+        except PacketFile.DoesNotExist:
+            pass
+
+        return redirect(project)
+
+
+@method_decorator(login_required, name='dispatch')
+class SelectDocumentView(DelegatedViewMixin, CreateView):
+    form_class = SelectDocumentForm
+    context_name = 'select_document_form'
+    model = PacketFile
+
+    @property
+    def should_show(self):
+        return bool(self.files)
+
+    def dispatch(self, request, *args, **kwargs):
+        self.project = get_object_or_404(Project, pk=self.kwargs['pk'])
+        return super().dispatch(request, *args, **kwargs)
+
+    def form_invalid(self, form):
+        for error in form.errors:
+            messages.error(self.request, error)
+
+        return redirect(self.project)
+
+    def form_valid(self, form):
+        form.instance.project = self.project
+        form.instance.name = form.instance.document.name
+
+        response = super().form_valid(form)
+
+        messenger(
+            MESSAGES.UPLOAD_DOCUMENT,
+            request=self.request,
+            user=self.request.user,
+            source=self.project,
+        )
+
+        return response
+
+    def get_form_kwargs(self):
+        kwargs = super().get_form_kwargs()
+        kwargs.pop('user')
+        kwargs.pop('instance')
+        kwargs['existing_files'] = get_files(self.get_parent_object())
+        return kwargs
+
+
+# GENERAL FORM VIEWS
+
+@method_decorator(staff_required, name='dispatch')
+class UpdateLeadView(DelegatedViewMixin, UpdateView):
+    model = Project
+    form_class = UpdateProjectLeadForm
+    context_name = 'lead_form'
+
+    def form_valid(self, form):
+        # Fetch the old lead from the database
+        old_lead = copy(self.get_object().lead)
+
+        response = super().form_valid(form)
+
+        messenger(
+            MESSAGES.UPDATE_PROJECT_LEAD,
+            request=self.request,
+            user=self.request.user,
+            source=form.instance,
+            related=old_lead or 'Unassigned',
+        )
+
+        return response
+
+
+# CONTRACTS
+
+class ContractsMixin:
+    def get_context_data(self, **kwargs):
+        project = self.get_object()
+        contracts = project.contracts.select_related(
+            'approver',
+        ).order_by('-created_at')
+
+        latest_contract = contracts.first()
+        contract_to_approve = None
+        contract_to_sign = None
+        if latest_contract:
+            if not latest_contract.is_signed:
+                contract_to_sign = latest_contract
+            elif not latest_contract.approver:
+                contract_to_approve = latest_contract
+
+        context = super().get_context_data(**kwargs)
+        context['contract_to_approve'] = contract_to_approve
+        context['contract_to_sign'] = contract_to_sign
+        context['contracts'] = contracts.approved()
+        return context
+
+
+@method_decorator(staff_required, name='dispatch')
+class ApproveContractView(DelegatedViewMixin, UpdateView):
+    form_class = ApproveContractForm
+    model = Contract
+    context_name = 'approve_contract_form'
+
+    def get_object(self):
+        project = self.get_parent_object()
+        latest_contract = project.contracts.order_by('-created_at').first()
+        if latest_contract and not latest_contract.approver:
+            return latest_contract
+
+    def get_form_kwargs(self):
+        kwargs = super().get_form_kwargs()
+        kwargs['instance'] = self.get_object()
+        kwargs.pop('user')
+        return kwargs
+
+    def dispatch(self, request, *args, **kwargs):
+        self.project = get_object_or_404(Project, pk=self.kwargs['pk'])
+        return super().dispatch(request, *args, **kwargs)
+
+    def form_invalid(self, form):
+        messages.error(self.request, mark_safe(_('Sorry something went wrong') + form.errors.as_ul()))
+        return super().form_invalid(form)
+
+    def form_valid(self, form):
+        with transaction.atomic():
+            form.instance.approver = self.request.user
+            form.instance.project = self.project
+            response = super().form_valid(form)
+
+            old_stage = self.project.get_status_display()
+
+            messenger(
+                MESSAGES.APPROVE_CONTRACT,
+                request=self.request,
+                user=self.request.user,
+                source=self.project,
+                related=self.object,
+            )
+
+            self.project.status = IN_PROGRESS
+            self.project.save(update_fields=['status'])
+
+            messenger(
+                MESSAGES.PROJECT_TRANSITION,
+                request=self.request,
+                user=self.request.user,
+                source=self.project,
+                related=old_stage,
+            )
+
+        return response
+
+    def get_success_url(self):
+        return self.project.get_absolute_url()
+
+
+@method_decorator(login_required, name='dispatch')
+class UploadContractView(DelegatedViewMixin, CreateView):
+    context_name = 'contract_form'
+    model = Project
+
+    def dispatch(self, request, *args, **kwargs):
+        response = super().dispatch(request, *args, **kwargs)
+
+        project = self.kwargs['object']
+        is_owner = project.user == request.user
+        if not (request.user.is_apply_staff or is_owner):
+            raise PermissionDenied
+
+        return response
+
+    def get_form_class(self):
+        if self.request.user.is_apply_staff:
+            return StaffUploadContractForm
+        return UploadContractForm
+
+    def get_form_kwargs(self):
+        kwargs = super().get_form_kwargs()
+        kwargs.pop('instance')
+        kwargs.pop('user')
+        return kwargs
+
+    def form_valid(self, form):
+        project = self.kwargs['object']
+
+        form.instance.project = project
+
+        if self.request.user == project.user:
+            form.instance.is_signed = True
+
+        response = super().form_valid(form)
+
+        messenger(
+            MESSAGES.UPLOAD_CONTRACT,
+            request=self.request,
+            user=self.request.user,
+            source=project,
+            related=form.instance,
+        )
+
+        return response
+
+
+# PROJECT VIEW
+class BaseProjectDetailView(DetailView):
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context['statuses'] = PROJECT_STATUS_CHOICES
+        context['current_status_index'] = [status for status, _ in PROJECT_STATUS_CHOICES].index(self.object.status)
+        return context
+
+
+class AdminProjectDetailView(
+    ActivityContextMixin,
+    DelegateableView,
+    ContractsMixin,
+    BaseProjectDetailView,
+):
+    form_views = [
+        ApproveContractView,
+        CommentFormView,
+        CreateApprovalView,
+        RejectionView,
+        RemoveDocumentView,
+        SelectDocumentView,
+        SendForApprovalView,
+        UpdateLeadView,
+        UploadContractView,
+        UploadDocumentView,
+    ]
+    model = Project
+    template_name_suffix = '_admin_detail'
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context['approvals'] = self.object.approvals.distinct('by')
+        context['remaining_document_categories'] = list(self.object.get_missing_document_categories())
+        return context
+
+
+class ApplicantProjectDetailView(
+    ActivityContextMixin,
+    DelegateableView,
+    ContractsMixin,
+    BaseProjectDetailView,
+):
+    form_views = [
+        CommentFormView,
+        SelectDocumentView,
+        UploadContractView,
+        UploadDocumentView,
+    ]
+
+    model = Project
+    template_name_suffix = '_applicant_detail'
+
+    def dispatch(self, request, *args, **kwargs):
+        project = self.get_object()
+        if project.user != request.user:
+            raise PermissionDenied
+        return super().dispatch(request, *args, **kwargs)
+
+
+class ProjectDetailView(ViewDispatcher):
+    admin_view = AdminProjectDetailView
+    applicant_view = ApplicantProjectDetailView
+
+
+@method_decorator(login_required, name='dispatch')
+class ProjectPrivateMediaView(UserPassesTestMixin, PrivateMediaView):
+    raise_exception = True
+
+    def dispatch(self, *args, **kwargs):
+        project_pk = self.kwargs['pk']
+        self.project = get_object_or_404(Project, pk=project_pk)
+        return super().dispatch(*args, **kwargs)
+
+    def get_media(self, *args, **kwargs):
+        document = PacketFile.objects.get(pk=kwargs['file_pk'])
+        if document.project != self.project:
+            raise Http404
+        return document.document
+
+    def test_func(self):
+        if self.request.user.is_apply_staff:
+            return True
+
+        if self.request.user == self.project.user:
+            return True
+
+        return False
+
+
+@method_decorator(login_required, name='dispatch')
+class ContractPrivateMediaView(UserPassesTestMixin, PrivateMediaView):
+    raise_exception = True
+
+    def dispatch(self, *args, **kwargs):
+        project_pk = self.kwargs['pk']
+        self.project = get_object_or_404(Project, pk=project_pk)
+        return super().dispatch(*args, **kwargs)
+
+    def get_media(self, *args, **kwargs):
+        document = Contract.objects.get(pk=kwargs['file_pk'])
+        if document.project != self.project:
+            raise Http404
+        return document.file
+
+    def test_func(self):
+        if self.request.user.is_apply_staff:
+            return True
+
+        if self.request.user == self.project.user:
+            return True
+
+        return False
+
+
+# PROJECT EDIT
+
+@method_decorator(staff_required, name='dispatch')
+class ProjectDetailSimplifiedView(DetailView):
+    model = Project
+    template_name_suffix = '_simplified_detail'
+
+
+class ProjectApprovalEditView(UpdateView):
+    form_class = ProjectApprovalForm
+    model = Project
+
+    def dispatch(self, request, *args, **kwargs):
+        project = self.get_object()
+        if not project.editable_by(request.user):
+            messages.info(self.request, _('You are not allowed to edit the project at this time'))
+            return redirect(project)
+        return super().dispatch(request, *args, **kwargs)
+
+    @cached_property
+    def approval_form(self):
+        if self.object.get_defined_fields():
+            approval_form = self.object
+        else:
+            approval_form = self.object.submission.page.specific.approval_form
+
+        return approval_form
+
+    def get_form_kwargs(self):
+        kwargs = super().get_form_kwargs()
+
+        if self.approval_form:
+            fields = self.approval_form.get_form_fields()
+        else:
+            fields = {}
+
+        kwargs['extra_fields'] = fields
+        kwargs['initial'].update(self.object.raw_data)
+        return kwargs
+
+    def form_valid(self, form):
+        try:
+            form_fields = self.approval_form.form_fields
+        except AttributeError:
+            form_fields = []
+
+        form.instance.form_fields = form_fields
+
+        return super().form_valid(form)
+
+
+class ApplicantProjectEditView(UpdateView):
+    form_class = ProjectEditForm
+    model = Project
+
+    def dispatch(self, request, *args, **kwargs):
+        project = self.get_object()
+        # This view is only for applicants.
+        if project.user != request.user:
+            raise PermissionDenied
+
+        if not project.editable_by(request.user):
+            messages.info(self.request, _('You are not allowed to edit the project at this time'))
+            return redirect(project)
+
+        return super().dispatch(request, *args, **kwargs)
+
+
+class ProjectEditView(ViewDispatcher):
+    admin_view = ProjectApprovalEditView
+    applicant_view = ApplicantProjectEditView
+
+
+@method_decorator(staff_required, name='dispatch')
+class ProjectListView(SingleTableMixin, FilterView):
+    filterset_class = ProjectListFilter
+    model = Project
+    table_class = ProjectsListTable
+    template_name = 'application_projects/project_list.html'
+
+    def get_queryset(self):
+        return Project.objects.for_table()
+
+
+@method_decorator(staff_required, name='dispatch')
+class ProjectOverviewView(TemplateView):
+    template_name = 'application_projects/overview.html'
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context['projects'] = self.get_projects(self.request)
+        context['payment_requests'] = self.get_payment_requests(self.request)
+        context['status_counts'] = self.get_status_counts()
+        return context
+
+    def get_payment_requests(self, request):
+        payment_requests = PaymentRequest.objects.order_by('date_to')[:10]
+
+        return {
+            'filterset': PaymentRequestListFilter(request.GET or None, request=request, queryset=payment_requests),
+            'table': PaymentRequestsListTable(payment_requests, order_by=()),
+            'url': reverse('apply:projects:payments:all'),
+        }
+
+    def get_projects(self, request):
+        projects = Project.objects.for_table()[:10]
+
+        return {
+            'filterset': ProjectListFilter(request.GET or None, request=request, queryset=projects),
+            'table': ProjectsListTable(projects, order_by=()),
+            'url': reverse('apply:projects:all'),
+        }
+
+    def get_status_counts(self):
+        status_counts = dict(
+            Project.objects.all().values('status').annotate(
+                count=Count('status'),
+            ).values_list(
+                'status',
+                'count',
+            )
+        )
+
+        return {
+            key: {
+                'name': display,
+                'count': status_counts.get(key, 0),
+                'url': reverse_lazy("funds:projects:all") + '?status=' + key,
+            }
+            for key, display in PROJECT_STATUS_CHOICES
+        }
diff --git a/opentech/apply/stream_forms/blocks.py b/opentech/apply/stream_forms/blocks.py
index cb7f3ff6683382badc86b9016a654c7f00d4ecd5..86fcf0b4b968db919ad77541fbf7b874eb0cfb76 100644
--- a/opentech/apply/stream_forms/blocks.py
+++ b/opentech/apply/stream_forms/blocks.py
@@ -1,5 +1,6 @@
 # Credit to https://github.com/BertrandBordage for initial implementation
 import bleach
+from dateutil.parser import isoparse, parse
 from django_bleach.templatetags.bleach_tags import bleach_value
 
 from django import forms
@@ -57,6 +58,10 @@ class FormFieldBlock(StructBlock):
         field_kwargs = self.get_field_kwargs(struct_value)
         return self.get_field_class(struct_value)(**field_kwargs)
 
+    def decode(self, value):
+        """Convert JSON representation into actual python objects"""
+        return value
+
     def serialize(self, value, context):
         field_kwargs = self.get_field_kwargs(value)
         return {
@@ -124,7 +129,7 @@ class CharFieldBlock(OptionalFormFieldBlock):
     def get_searchable_content(self, value, data):
         # CharField acts as a fallback. Force data to string
         data = str(data)
-        return bleach.clean(data, tags=[], strip=True)
+        return bleach.clean(data or '', tags=[], strip=True)
 
 
 class TextFieldBlock(OptionalFormFieldBlock):
@@ -137,7 +142,7 @@ class TextFieldBlock(OptionalFormFieldBlock):
         template = 'stream_forms/render_unsafe_field.html'
 
     def get_searchable_content(self, value, data):
-        return bleach.clean(data, tags=[], strip=True)
+        return bleach.clean(data or '', tags=[], strip=True)
 
 
 class NumberFieldBlock(OptionalFormFieldBlock):
@@ -298,6 +303,10 @@ class DateFieldBlock(OptionalFormFieldBlock):
     def get_searchable_content(self, value, data):
         return None
 
+    def decode(self, value):
+        if value:
+            return parse(value).date()
+
 
 class HTML5TimeInput(forms.TimeInput):
     input_type = 'time'
@@ -316,6 +325,10 @@ class TimeFieldBlock(OptionalFormFieldBlock):
     def get_searchable_content(self, value, data):
         return None
 
+    def decode(self, value):
+        if value:
+            return parse(value).time()
+
 
 class DateTimePickerInput(forms.SplitDateTimeWidget):
     def __init__(self, attrs=None, date_format=None, time_format=None):
@@ -345,6 +358,10 @@ class DateTimeFieldBlock(OptionalFormFieldBlock):
     def get_searchable_content(self, value, data):
         return None
 
+    def decode(self, value):
+        if value:
+            return isoparse(value)
+
 
 class UploadableMediaBlock(OptionalFormFieldBlock):
     class Meta:
diff --git a/opentech/apply/stream_forms/models.py b/opentech/apply/stream_forms/models.py
index cfda19a6267faa1abacde766623dd80b0fde2c5b..ca60ffc3ee9f2247d69b71b8c01e4b6f81e4de77 100644
--- a/opentech/apply/stream_forms/models.py
+++ b/opentech/apply/stream_forms/models.py
@@ -10,6 +10,30 @@ from .forms import BlockFieldWrapper, PageStreamBaseForm
 class BaseStreamForm:
     submission_form_class = PageStreamBaseForm
 
+    @classmethod
+    def from_db(cls, db, field_names, values):
+        instance = super().from_db(db, field_names, values)
+        if 'form_data' in field_names:
+            instance.form_data = cls.deserialize_form_data(instance, instance.form_data, instance.form_fields)
+        return instance
+
+    @classmethod
+    def deserialize_form_data(cls, instance, form_data, form_fields):
+        data = form_data.copy()
+        # PERFORMANCE NOTE:
+        # Do not attempt to iterate over form_fields - that will fully instantiate the form_fields
+        # including any sub queries that they do
+        for i, field_data in enumerate(form_fields.stream_data):
+            block = form_fields.stream_block.child_blocks[field_data['type']]
+            field_id = field_data.get('id')
+            try:
+                value = data[field_id]
+            except KeyError:
+                pass
+            else:
+                data[field_id] = block.decode(value)
+        return data
+
     def get_defined_fields(self):
         return self.form_fields
 
diff --git a/opentech/apply/stream_forms/testing/factories.py b/opentech/apply/stream_forms/testing/factories.py
index f3421670610081f668ae6adeb5b2929cb521468d..045438cefae3c3480053ff85c6c5ad82e803440e 100644
--- a/opentech/apply/stream_forms/testing/factories.py
+++ b/opentech/apply/stream_forms/testing/factories.py
@@ -3,7 +3,7 @@ import json
 import uuid
 
 from django.core.files.uploadedfile import SimpleUploadedFile
-
+from django.core.serializers.json import DjangoJSONEncoder
 import factory
 from wagtail.core.blocks import RichTextBlock
 from wagtail.core.rich_text import RichText
@@ -11,10 +11,11 @@ import wagtail_factories
 
 from opentech.apply.stream_forms import blocks as stream_blocks
 
-__all__ = ['FormFieldBlockFactory', 'CharFieldBlockFactory',
-           'NumberFieldBlockFactory', 'RadioFieldBlockFactory',
-           'UploadableMediaFactory', 'ImageFieldBlockFactory',
-           'FileFieldBlockFactory', 'MultiFileFieldBlockFactory']
+__all__ = ['BLOCK_FACTORY_DEFINITION', 'FormFieldBlockFactory',
+           'CharFieldBlockFactory', 'NumberFieldBlockFactory',
+           'RadioFieldBlockFactory', 'UploadableMediaFactory',
+           'ImageFieldBlockFactory', 'FileFieldBlockFactory',
+           'MultiFileFieldBlockFactory']
 
 
 class AnswerFactory(factory.Factory):
@@ -99,12 +100,14 @@ class FormFieldBlockFactory(wagtail_factories.StructBlockFactory):
         model = stream_blocks.FormFieldBlock
 
     @classmethod
-    def make_answer(cls, params=dict()):
-        return cls.default_value.generate(params)
+    def make_answer(cls, params=None):
+        return cls.default_value.generate(params or {})
 
     @classmethod
-    def make_form_answer(cls, params=dict()):
-        return cls.make_answer(params)
+    def make_form_answer(cls, params=None):
+        if params:
+            return params
+        return cls.make_answer(params or {})
 
 
 class CharFieldBlockFactory(FormFieldBlockFactory):
@@ -114,6 +117,45 @@ class CharFieldBlockFactory(FormFieldBlockFactory):
         model = stream_blocks.CharFieldBlock
 
 
+class TextFieldBlockFactory(FormFieldBlockFactory):
+    default_value = factory.Faker('sentence')
+
+    class Meta:
+        model = stream_blocks.TextFieldBlock
+
+
+class DateFieldBlockFactory(FormFieldBlockFactory):
+    default_value = factory.Faker('date_object')
+
+    class Meta:
+        model = stream_blocks.DateFieldBlock
+
+
+class TimeFieldBlockFactory(FormFieldBlockFactory):
+    default_value = factory.Faker('time_object')
+
+    class Meta:
+        model = stream_blocks.TimeFieldBlock
+
+
+class DateTimeFieldBlockFactory(FormFieldBlockFactory):
+    default_value = factory.Faker('date_time')
+
+    class Meta:
+        model = stream_blocks.DateTimeFieldBlock
+
+    @classmethod
+    def make_form_answer(cls, params=None):
+        if params:
+            date_time = params
+        else:
+            date_time = super().make_form_answer(params)
+        return {
+            'date': str(date_time.date()),
+            'time': str(date_time.time()),
+        }
+
+
 class NumberFieldBlockFactory(FormFieldBlockFactory):
     default_value = 100
 
@@ -121,10 +163,32 @@ class NumberFieldBlockFactory(FormFieldBlockFactory):
         model = stream_blocks.NumberFieldBlock
 
     @classmethod
-    def make_answer(cls, params=dict()):
+    def make_answer(cls, params=None):
         return cls.default_value
 
 
+class CheckboxFieldBlockFactory(FormFieldBlockFactory):
+    choices = ['check_one', 'check_two']
+
+    class Meta:
+        model = stream_blocks.CheckboxFieldBlock
+
+    @classmethod
+    def make_answer(cls, params=None):
+        return cls.choices[0]
+
+
+class CheckboxesFieldBlockFactory(FormFieldBlockFactory):
+    checkboxes = ['check_one', 'check_two', 'check_three']
+
+    class Meta:
+        model = stream_blocks.CheckboxesFieldBlock
+
+    @classmethod
+    def make_answer(cls, params=None):
+        return cls.checkboxes[0:2]
+
+
 class RadioFieldBlockFactory(FormFieldBlockFactory):
     choices = ['first', 'second']
 
@@ -132,7 +196,18 @@ class RadioFieldBlockFactory(FormFieldBlockFactory):
         model = stream_blocks.RadioButtonsFieldBlock
 
     @classmethod
-    def make_answer(cls, params=dict()):
+    def make_answer(cls, params=None):
+        return cls.choices[0]
+
+
+class DropdownFieldBlockFactory(FormFieldBlockFactory):
+    choices = ['first', 'second']
+
+    class Meta:
+        model = stream_blocks.DropdownFieldBlock
+
+    @classmethod
+    def make_answer(cls, params=None):
         return cls.choices[0]
 
 
@@ -140,8 +215,8 @@ class UploadableMediaFactory(FormFieldBlockFactory):
     default_value = factory.django.FileField
 
     @classmethod
-    def make_answer(cls, params=dict()):
-        params = params.copy()
+    def make_answer(cls, params=None):
+        params = params or {}
         params.setdefault('data', b'this is some content')
         if params.get('filename') is None:
             params['filename'] = 'example.pdf'
@@ -166,7 +241,7 @@ class MultiFileFieldBlockFactory(UploadableMediaFactory):
         model = stream_blocks.MultiFileFieldBlock
 
     @classmethod
-    def make_answer(cls, params=dict()):
+    def make_answer(cls, params=None):
         return [UploadableMediaFactory.make_answer() for _ in range(2)]
 
 
@@ -180,7 +255,7 @@ class StreamFieldUUIDFactory(wagtail_factories.StreamFieldFactory):
             block = self.factories[block_name]._meta.model()
             value = block.get_prep_value(value)
             ret_val.append({'type': block_name, 'value': value, 'id': str(uuid.uuid4())})
-        return json.dumps(ret_val)
+        return json.dumps(ret_val, cls=DjangoJSONEncoder)
 
     def build_form(self, data):
         extras = defaultdict(dict)
@@ -216,7 +291,9 @@ class StreamFieldUUIDFactory(wagtail_factories.StreamFieldFactory):
 
         return form_fields
 
-    def form_response(self, fields, field_values=dict()):
+    def form_response(self, fields, field_values=None):
+        if not field_values:
+            field_values = {}
         data = {
             field.id: self.factories[field.block.name].make_form_answer(field_values.get(field.id, {}))
             for field in fields
@@ -225,6 +302,26 @@ class StreamFieldUUIDFactory(wagtail_factories.StreamFieldFactory):
         return flatten_for_form(data)
 
 
+BLOCK_FACTORY_DEFINITION = {
+    'text_markup': ParagraphBlockFactory,
+    'char': CharFieldBlockFactory,
+    'text': TextFieldBlockFactory,
+    'number': NumberFieldBlockFactory,
+    'checkbox': CheckboxFieldBlockFactory,
+    'radios': RadioFieldBlockFactory,
+    'dropdown': DropdownFieldBlockFactory,
+    'checkboxes': CheckboxesFieldBlockFactory,
+    'date': DateFieldBlockFactory,
+    'time': TimeFieldBlockFactory,
+    'datetime': DateTimeFieldBlockFactory,
+    'image': ImageFieldBlockFactory,
+    'file': FileFieldBlockFactory,
+    'multi_file': MultiFileFieldBlockFactory,
+}
+
+FormFieldsBlockFactory = StreamFieldUUIDFactory(BLOCK_FACTORY_DEFINITION)
+
+
 def flatten_for_form(data, field_name='', number=False):
     result = {}
     for i, (field, value) in enumerate(data.items()):
diff --git a/opentech/apply/stream_forms/tests.py b/opentech/apply/stream_forms/tests.py
index 2a861023ffd8f1b6bb9d7173b2b00c0ed85a0a48..52fe2970de36c2393091a69f5501b5d17507c2b9 100644
--- a/opentech/apply/stream_forms/tests.py
+++ b/opentech/apply/stream_forms/tests.py
@@ -5,6 +5,7 @@ from faker import Faker
 
 from .files import StreamFieldFile
 from .fields import MultiFileField, MultiFileInput
+from .blocks import FormFieldBlock, FormFieldsBlock
 
 fake = Faker()
 
@@ -87,3 +88,12 @@ class TestMultiFileField(TestCase):
         initial_files = make_files(3)
         cleaned = self.field.clean(self.multi_file_value(cleared=[0, 2]), initial_files)
         self.assertEqual([initial_files[1]], cleaned)
+
+
+class TestBlocks(TestCase):
+    def test_blocks_decode_none(self):
+        for block in FormFieldsBlock().child_blocks.values():
+            if isinstance(block, FormFieldBlock):
+                with self.subTest(block=block):
+                    value = block.decode(None)
+                    self.assertIsNone(value)
diff --git a/opentech/apply/users/decorators.py b/opentech/apply/users/decorators.py
index 26d4016f57356e56ce077dfa4c40d012ee2ccef1..b41a4aaee89d268e53e97700d2fb91b8d897377c 100644
--- a/opentech/apply/users/decorators.py
+++ b/opentech/apply/users/decorators.py
@@ -19,8 +19,16 @@ def is_apply_staff(user):
     return True
 
 
+def is_approver(user):
+    if not user.is_approver:
+        raise PermissionDenied
+    return True
+
+
 staff_required = [login_required, user_passes_test(is_apply_staff)]
 
+approver_required = [login_required, user_passes_test(is_approver)]
+
 
 def superuser_decorator(fn):
     check = user_passes_test(lambda user: user.is_superuser)
diff --git a/opentech/apply/users/models.py b/opentech/apply/users/models.py
index 768b583c958865dcd2851c39fa5ffeb686d3845b..1c60d84be3766d124aa41c2a7616f04ba16a8a1a 100644
--- a/opentech/apply/users/models.py
+++ b/opentech/apply/users/models.py
@@ -2,7 +2,6 @@ from django.contrib.auth.hashers import make_password
 from django.contrib.auth.models import AbstractUser, BaseUserManager, Group
 from django.db import models
 from django.db.models import Q
-from django.urls import reverse
 from django.utils.functional import cached_property
 from django.utils.translation import gettext_lazy as _
 
@@ -102,9 +101,6 @@ class User(AbstractUser):
 
     objects = UserManager()
 
-    def get_absolute_url(self):
-        return reverse('wagtailusers_users:edit', args=(self.id,))
-
     def __str__(self):
         return self.get_full_name() if self.get_full_name() else self.get_short_name()
 
diff --git a/opentech/apply/users/wagtail_hooks.py b/opentech/apply/users/wagtail_hooks.py
index 19355eb0e2e4a854fd46168cbf24aecb701d4e62..15783a07d7bf950a23e16a18bffe5477636be0ac 100644
--- a/opentech/apply/users/wagtail_hooks.py
+++ b/opentech/apply/users/wagtail_hooks.py
@@ -1,4 +1,5 @@
 from django.conf.urls import url
+from django.urls import reverse
 
 from wagtail.core import hooks
 
@@ -20,6 +21,7 @@ def notify_after_create_user(request, user):
         message=f'{request.user} has crated a new account for {user}.',
         request=request,
         related=user,
+        path=reverse('wagtailusers_users:edit', args=(user.id,))
     )
 
 
@@ -34,4 +36,5 @@ def notify_after_edit_user(request, user):
             message=f'{request.user} has edited the account for {user} that now have these roles: {roles}.',
             request=request,
             related=user,
+            path=reverse('wagtailusers_users:edit', args=(user.id,))
         )
diff --git a/opentech/apply/utils/notifications.py b/opentech/apply/utils/notifications.py
index bed3683da90108043926563a59f6c05e7d8f3178..037b23a32bec317028ad8baf077e2ad85e4f7c92 100644
--- a/opentech/apply/utils/notifications.py
+++ b/opentech/apply/utils/notifications.py
@@ -19,16 +19,15 @@ class SlackNotifications():
                 slack_users.append(f'<{user.slack}>')
         return ' '.join(slack_users)
 
-    def slack_link(self, request, related):
-        slack_link = ''
+    def slack_link(self, request, related, path=None, **kwargs):
         try:
-            link = request.scheme + '://' + request.get_host() + related.get_absolute_url()
+            url = path or related.get_absolute_url()
         except AttributeError:
-            pass
-        else:
-            title = str(related)
-            slack_link = f'<{link}|{title}>'
-        return slack_link
+            return ''
+
+        link = request.scheme + '://' + request.get_host() + url
+        title = str(related)
+        return f'<{link}|{title}>'
 
     def send_message(self, message, request, recipients=None, related=None, **kwargs):
         if not self.destination or not self.target_room:
@@ -41,7 +40,7 @@ class SlackNotifications():
 
         slack_users = self.slack_users(recipients) if recipients else ''
 
-        slack_link = self.slack_link(request, related) if related else ''
+        slack_link = self.slack_link(request, related, **kwargs) if related else ''
 
         message = ' '.join([slack_users, message, slack_link]).strip()
 
diff --git a/opentech/apply/utils/templatetags/__init__.py b/opentech/apply/utils/templatetags/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/opentech/apply/utils/templatetags/apply_tags.py b/opentech/apply/utils/templatetags/apply_tags.py
new file mode 100644
index 0000000000000000000000000000000000000000..c010e4d6cefb172eecdce993a6aff3e292da214b
--- /dev/null
+++ b/opentech/apply/utils/templatetags/apply_tags.py
@@ -0,0 +1,9 @@
+from django import template
+
+register = template.Library()
+
+
+# Get the verbose name of a model instance
+@register.filter
+def model_verbose_name(instance):
+    return instance._meta.verbose_name.title()
diff --git a/opentech/apply/utils/testing/tests.py b/opentech/apply/utils/testing/tests.py
index 2940aceb26b441b25726c68255964da9d9f1db54..b52c1d389828854a3fcb0aff8d0ef2d5891037b6 100644
--- a/opentech/apply/utils/testing/tests.py
+++ b/opentech/apply/utils/testing/tests.py
@@ -36,7 +36,7 @@ class BaseViewTestCase(TestCase):
     def get_kwargs(self, instance):
         return {}
 
-    def url(self, instance, view_name=None, absolute=True, kwargs=dict()):
+    def url(self, instance, view_name=None, absolute=True, url_kwargs=None):
         view = view_name or self.base_view_name
         full_url_name = self.url_name.format(view)
         kwargs_method = f'get_{view}_kwargs'
@@ -44,6 +44,8 @@ class BaseViewTestCase(TestCase):
             kwargs = getattr(self, kwargs_method)(instance)
         else:
             kwargs = self.get_kwargs(instance)
+        if url_kwargs:
+            kwargs.update(url_kwargs)
         return self.url_from_pattern(full_url_name, kwargs, secure=True, absolute=absolute)
 
     def absolute_url(self, location, secure=True):
@@ -57,11 +59,11 @@ class BaseViewTestCase(TestCase):
         request = self.factory.get(url, secure=secure)
         return request.path
 
-    def get_page(self, instance=None, view_name=None):
-        return self.client.get(self.url(instance, view_name), secure=True, follow=True)
+    def get_page(self, instance=None, view_name=None, url_kwargs=None):
+        return self.client.get(self.url(instance, view_name, url_kwargs=url_kwargs), secure=True, follow=True)
 
-    def post_page(self, instance=None, data=dict(), view_name=None):
-        return self.client.post(self.url(instance, view_name), data, secure=True, follow=True)
+    def post_page(self, instance=None, data=dict(), view_name=None, url_kwargs=None):
+        return self.client.post(self.url(instance, view_name, url_kwargs=url_kwargs), data, secure=True, follow=True)
 
     def refresh(self, instance):
         return instance.__class__.objects.get(id=instance.id)
diff --git a/opentech/apply/utils/tests/test_views.py b/opentech/apply/utils/tests/test_views.py
new file mode 100644
index 0000000000000000000000000000000000000000..3b7e4204c8959cecb2a2e725ff4abd3536cd0f59
--- /dev/null
+++ b/opentech/apply/utils/tests/test_views.py
@@ -0,0 +1,27 @@
+from django.test import TestCase
+from django.views.generic import UpdateView
+
+from opentech.apply.utils.views import DelegatedViewMixin
+
+
+class PatchedUpdateView(UpdateView):
+    def get_object(self):
+        return 1
+
+
+class DelegatedView(DelegatedViewMixin, PatchedUpdateView):
+    model = int  # pretend int is a model for the isinstance check
+
+    def get_parent_kwargs(self):
+        return {'instance': 3}
+
+
+class TestDelegatedViewMixin(TestCase):
+    def test_parent_access_if_no_object(self):
+        self.assertEqual(DelegatedView().get_object(), 3)
+
+    def test__access_if_no_object(self):
+        view = DelegatedView()
+        view.object = 3
+        self.assertNotEqual(view.get_object(), 3)
+        self.assertEqual(view.get_object(), 1)
diff --git a/opentech/apply/utils/views.py b/opentech/apply/utils/views.py
index 6ea0fb1673f27e375ac389147740534bf6a3ed73..5f01b74d6cf95f1c7d5e2559d5edb9b9f19d6b68 100644
--- a/opentech/apply/utils/views.py
+++ b/opentech/apply/utils/views.py
@@ -7,6 +7,7 @@ from django.views.generic import View
 from django.views.generic.base import ContextMixin
 from django.views.generic.detail import SingleObjectTemplateResponseMixin
 from django.views.generic.edit import ModelFormMixin, ProcessFormView
+from django.shortcuts import redirect
 
 
 def page_not_found(request, exception=None, template_name='apply/404.html'):
@@ -60,12 +61,22 @@ class DelegatableBase(ContextMixin):
     """
     form_prefix = 'form-submitted-'
 
+    def __init__(self, *args, **kwargs):
+        self._form_views = {
+            self.form_prefix + form_view.context_name: form_view
+            for form_view in self.form_views
+        }
+
     def get_form_kwargs(self):
         return {}
 
     def get_context_data(self, **kwargs):
-        form_kwargs = self.get_form_kwargs()
-        forms = dict(form_view.contribute_form(**form_kwargs) for form_view in self.form_views)
+        forms = {}
+        for form_view in self._form_views.values():
+            view = form_view()
+            view.setup(self.request, self.args, self.kwargs)
+            context_key, form = view.contribute_form(self)
+            forms[context_key] = form
 
         return super().get_context_data(
             form_prefix=self.form_prefix,
@@ -78,12 +89,12 @@ class DelegatableBase(ContextMixin):
         kwargs['context'] = self.get_context_data()
         kwargs['template_names'] = self.get_template_names()
 
-        for form_view in self.form_views:
-            if self.form_prefix + form_view.context_name in request.POST:
-                return form_view.as_view()(request, *args, **kwargs)
+        for form_key, form_view in self._form_views.items():
+            if form_key in request.POST:
+                return form_view.as_view()(request, *args, parent=self, **kwargs)
 
         # Fall back to get if not form exists as submitted
-        return self.get(request, *args, **kwargs)
+        return redirect(request.path)
 
 
 class DelegateableView(DelegatableBase):
@@ -115,17 +126,49 @@ class DelegateableListView(DelegatableBase):
 class DelegatedViewMixin(View):
     """For use on create views accepting forms from another view"""
 
+    # TODO: REMOVE IN DJANGO 2.2
+    def setup(self, request, *args, **kwargs):
+        """Initialize attributes shared by all view methods."""
+        self.request = request
+        self.args = args
+        self.kwargs = kwargs
+
+    def get_object(self):
+        # Make sure the form instance, bound at the parent class level,  is the same as the
+        # value we work with on the class.
+        # If we don't have self.object, bind the parent instance to it. This value will then
+        # be used by the form. Any further calls to get_object will get a new instance of the object
+        if not hasattr(self, 'object'):
+            parent_object = self.get_parent_object()
+            if isinstance(parent_object, self.model):
+                return parent_object
+
+        return super().get_object()
+
     def get_template_names(self):
         return self.kwargs['template_names']
 
+    def get_form_name(self):
+        return self.context_name
+
     def get_form_kwargs(self):
-        kwargs = super().get_form_kwargs()
-        kwargs['user'] = self.request.user
-        return kwargs
+        form_kwargs = super().get_form_kwargs()
+        form_kwargs['user'] = self.request.user
+        form_kwargs.update(**self.get_parent_kwargs())
+        return form_kwargs
+
+    def get_parent_kwargs(self):
+        try:
+            return self.parent.get_form_kwargs()
+        except AttributeError:
+            return self.kwargs['parent'].get_form_kwargs()
+
+    def get_parent_object(self):
+        return self.get_parent_kwargs()['instance']
 
     def get_form(self, *args, **kwargs):
         form = super().get_form(*args, **kwargs)
-        form.name = self.context_name
+        form.name = self.get_form_name()
         return form
 
     def get_context_data(self, **kwargs):
@@ -139,11 +182,21 @@ class DelegatedViewMixin(View):
     def is_model_form(cls):
         return issubclass(cls.form_class, ModelForm)
 
-    @classmethod
-    def contribute_form(cls, **kwargs):
-        form = cls.form_class(**kwargs)
-        form.name = cls.context_name
-        return cls.context_name, form
+    def contribute_form(self, parent):
+        self.parent = parent
+
+        # We do not want to bind any forms generated this way
+        # pretend we are doing a get request to avoid passing data to forms
+        old_method = None
+        if self.request.method in ('POST', 'PUT'):
+            old_method = self.request.method
+            self.request.method = 'GET'
+
+        form = self.get_form()
+
+        if old_method:
+            self.request.method = old_method
+        return self.context_name, form
 
     def get_success_url(self):
         query = self.request.GET.urlencode()
diff --git a/opentech/public/navigation/templates/navigation/primarynav-apply.html b/opentech/public/navigation/templates/navigation/primarynav-apply.html
index a48968ebc8b9f5415e12efe286bd9d25af079341..f6286eaba49f09a1d90fa5231e44c9521e26a671 100644
--- a/opentech/public/navigation/templates/navigation/primarynav-apply.html
+++ b/opentech/public/navigation/templates/navigation/primarynav-apply.html
@@ -3,6 +3,7 @@
         {% if request.user.is_apply_staff %}
             {% include "navigation/primarynav-apply-item.html" with name="Dashboard" url="dashboard:dashboard" %}
             {% include "navigation/primarynav-apply-item.html" with name="Submissions" url="funds:submissions:overview" %}
+            {% include "navigation/primarynav-apply-item.html" with name="Projects" url="apply:projects:overview" %}
         {% else %}
             {% include "navigation/primarynav-apply-item.html" with name="Dashboard" url="dashboard:dashboard" %}
         {% endif %}
diff --git a/opentech/settings/base.py b/opentech/settings/base.py
index fc23648e40ec88f2ae3fff3234564afd12a0bd8d..3a19783090da2fddc9072c8b705a2893538033e4 100644
--- a/opentech/settings/base.py
+++ b/opentech/settings/base.py
@@ -639,3 +639,7 @@ REST_FRAMEWORK = {
 PROJECTS_ENABLED = False
 if env.get('PROJECTS_ENABLED', 'false').lower().strip() == 'true':
     PROJECTS_ENABLED = True
+
+PROJECTS_AUTO_CREATE = False
+if env.get('PROJECTS_AUTO_CREATE', 'false').lower().strip() == 'true':
+    PROJECTS_AUTO_CREATE = True
diff --git a/opentech/settings/dev.py b/opentech/settings/dev.py
index 01e2301e5edaf0e08dfaff107651eaab54c39f06..873783137117905ee720e1cac3b58f82c0af3732 100644
--- a/opentech/settings/dev.py
+++ b/opentech/settings/dev.py
@@ -35,6 +35,7 @@ except ImportError:
     pass
 
 PROJECTS_ENABLED = True
+PROJECTS_AUTO_CREATE = True
 
 # We add these here so they can react on settings made in local.py.
 
diff --git a/opentech/settings/test.py b/opentech/settings/test.py
index 3d6102ba8bff38edc37cef28a368432054de92c1..b5990e4589a7d9032045a653a0bcd2d9ad501a14 100644
--- a/opentech/settings/test.py
+++ b/opentech/settings/test.py
@@ -9,6 +9,7 @@ from .base import *  # noqa
 SECRET_KEY = 'NOT A SECRET'
 
 PROJECTS_ENABLED = True
+PROJECTS_AUTO_CREATE = True
 
 # Need this to ensure white noise doesn't kill the speed of testing
 # http://whitenoise.evans.io/en/latest/django.html#whitenoise-makes-my-tests-run-slow
diff --git a/opentech/static_src/src/images/cross.svg b/opentech/static_src/src/images/cross.svg
new file mode 100644
index 0000000000000000000000000000000000000000..b818cbe1e797035b9f6b724613df989d873caf2f
--- /dev/null
+++ b/opentech/static_src/src/images/cross.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 21.9 21.9" xmlns="http://www.w3.org/2000/svg">
+    <path fill="#f05e54" d="M14.1 11.3c-.2-.2-.2-.5 0-.7l7.5-7.5c.2-.2.3-.5.3-.7s-.1-.5-.3-.7L20.2.3c-.2-.2-.5-.3-.7-.3-.3 0-.5.1-.7.3l-7.5 7.5c-.2.2-.5.2-.7 0L3.1.3C2.9.1 2.6 0 2.4 0s-.5.1-.7.3L.3 1.7c-.2.2-.3.5-.3.7s.1.5.3.7l7.5 7.5c.2.2.2.5 0 .7L.3 18.8c-.2.2-.3.5-.3.7s.1.5.3.7l1.4 1.4c.2.2.5.3.7.3s.5-.1.7-.3l7.5-7.5c.2-.2.5-.2.7 0l7.5 7.5c.2.2.5.3.7.3s.5-.1.7-.3l1.4-1.4c.2-.2.3-.5.3-.7s-.1-.5-.3-.7l-7.5-7.5z"/>
+</svg>
diff --git a/opentech/static_src/src/javascript/apply/submission-filters.js b/opentech/static_src/src/javascript/apply/submission-filters.js
index c89cea1431f76204072080fa5a5725ced7e4a2a4..876d4abddd132a6e82d201d88d376c4cad14a2fa 100644
--- a/opentech/static_src/src/javascript/apply/submission-filters.js
+++ b/opentech/static_src/src/javascript/apply/submission-filters.js
@@ -3,17 +3,12 @@
     'use strict';
 
     // Variables
-    const $body = $('body');
     const $toggleButton = $('.js-toggle-filters');
     const $closeButton = $('.js-close-filters');
     const $clearButton = $('.js-clear-filters');
     const filterOpenClass = 'filters-open';
     const filterActiveClass = 'is-active';
 
-    const $searchInput = $('.js-search-input');
-    const $queryInput = $('#id_query');
-    const $searchForm = $('.js-search-form');
-
     const $filterForm = $('.js-filter-form');
 
     const urlParams = new URLSearchParams(window.location.search);
@@ -27,19 +22,13 @@
     );
 
     if ([...urlParams].length > minimumNumberParams && $(window).width() > 1024) {
-        $body.addClass(filterOpenClass);
-        updateButtonText();
+        $('.filters').addClass(filterOpenClass);
+        $('.js-toggle-filters').text('Clear filters');
     }
 
-    $searchForm.submit((e) => {
-        e.preventDefault();
-        $queryInput.val($searchInput.val());
-        $filterForm.submit();
-    });
-
     // Add active class to filters - dropdowns are dynamically appended to the dom,
     // so we have to listen for the event higher up
-    $body.on('click', '.select2-dropdown', (e) => {
+    $('body').on('click', '.select2-dropdown', (e) => {
         // get the id of the dropdown
         let selectId = e.target.parentElement.parentElement.id;
 
@@ -64,20 +53,25 @@
     });
 
     // toggle filters
-    $toggleButton.on('click', () => {
-        if ($body.hasClass(filterOpenClass)) {
+    $toggleButton.on('click', (e) => {
+        // find the nearest filters
+        const filters = e.target.closest('.js-table-actions').nextElementSibling;
+
+        if (filters.classList.contains(filterOpenClass)) {
             handleClearFilters();
         }
         else {
-            $body.addClass(filterOpenClass);
-            updateButtonText();
+            filters.classList.add(filterOpenClass);
+            // only update button text on desktop
+            if (window.innerWidth >= 1024) {
+                updateButtonText(e.target, filters);
+            }
         }
     });
 
     // close filters on mobile
     $closeButton.on('click', (e) => {
-        $body.removeClass(filterOpenClass);
-        updateButtonText();
+        e.target.closest('.filters').classList.remove(filterOpenClass);
     });
 
     // redirect to submissions home to clear filters
@@ -88,12 +82,12 @@
     }
 
     // toggle filters button wording
-    function updateButtonText() {
-        if ($body.hasClass(filterOpenClass)) {
-            $toggleButton.text('Clear filters');
+    function updateButtonText(button, filters) {
+        if (filters.classList.contains(filterOpenClass)) {
+            button.textContent = 'Clear filters';
         }
         else {
-            $toggleButton.text('Filters');
+            button.textContent = 'Filters';
         }
     }
 
diff --git a/opentech/static_src/src/sass/apply/abstracts/_variables.scss b/opentech/static_src/src/sass/apply/abstracts/_variables.scss
index e104ee77515a074f7cb6651c9e37f43a07aded20..aadcc4a8f3d611d87a5ecc3edc852a17b5a54ec8 100644
--- a/opentech/static_src/src/sass/apply/abstracts/_variables.scss
+++ b/opentech/static_src/src/sass/apply/abstracts/_variables.scss
@@ -15,7 +15,9 @@ $color--mid-grey: #cfcfcf;
 $color--mid-dark-grey: #919191;
 
 // Brand
+$color--lightest-blue: #24aae1;
 $color--light-blue: #0d7db0;
+$color--lighter-blue: #7ab8d4;
 $color--dark-blue: #0c72a0;
 $color--darkest-blue: #3d6bdb;
 $color--mustard: #e6ab32;
@@ -60,6 +62,7 @@ $color--default: $color--dark-grey;
 $color--primary: $color--light-blue;
 $color--error: $color--tomato;
 $color--correct: $color--mint;
+$color--button-disabled: $color--lighter-blue;
 
 // Fonts
 $font--primary: 'noto-sans';
diff --git a/opentech/static_src/src/sass/apply/components/_all-submissions-table.scss b/opentech/static_src/src/sass/apply/components/_all-submissions-table.scss
index 13f5230c23f064fca76588186ded37ac3d002c9b..3d4474298f362c73b65b428e2b1684bea9f60527 100644
--- a/opentech/static_src/src/sass/apply/components/_all-submissions-table.scss
+++ b/opentech/static_src/src/sass/apply/components/_all-submissions-table.scss
@@ -91,7 +91,7 @@
                             white-space: normal;
                             background: $color--sky-blue;
                             border: 1px solid $color--marine;
-                            content: attr(data-tooltip);
+                            content: attr(data-title-tooltip);
                             opacity: 0;
                             transition: opacity $transition;
                         }
diff --git a/opentech/static_src/src/sass/apply/components/_button.scss b/opentech/static_src/src/sass/apply/components/_button.scss
index 9a1963268d8322077716280ad7bd3c63eb70a028..f5b89abb31e2b2d794f975eaf89f6b15934e514a 100644
--- a/opentech/static_src/src/sass/apply/components/_button.scss
+++ b/opentech/static_src/src/sass/apply/components/_button.scss
@@ -313,4 +313,17 @@
             transform: rotate(90deg);
         }
     }
+
+    &--link {
+        color: $color--primary;
+    }
+
+    &--tooltip-disabled {
+        background-color: $color--button-disabled;
+
+        &:hover {
+            cursor: default;
+            background-color: $color--button-disabled;
+        }
+    }
 }
diff --git a/opentech/static_src/src/sass/apply/components/_card.scss b/opentech/static_src/src/sass/apply/components/_card.scss
new file mode 100644
index 0000000000000000000000000000000000000000..497b6a1d764e5ae24b33395bedefe021f12054c8
--- /dev/null
+++ b/opentech/static_src/src/sass/apply/components/_card.scss
@@ -0,0 +1,43 @@
+.card {
+    padding: 20px;
+    margin-bottom: 20px;
+    border: 1px solid $color--mid-grey;
+
+    &--solid {
+        background-color: $color--white;
+    }
+
+    &__text {
+        margin: 0 0 1rem;
+
+        &:last-child {
+            margin: 0;
+        }
+    }
+
+    &__inner {
+        margin-bottom: 1rem;
+
+        &:last-child {
+            margin-bottom: 0;
+        }
+    }
+
+    &__heading {
+        margin-bottom: 0;
+    }
+
+    &__reviewer-outcome {
+        display: flex;
+        align-items: center;
+        margin-bottom: 1rem;
+
+        @include media-query(small-tablet) {
+            margin-left: 1rem;
+        }
+    }
+
+    &__reviewer {
+        margin-left: 10px;
+    }
+}
diff --git a/opentech/static_src/src/sass/apply/components/_docs-block.scss b/opentech/static_src/src/sass/apply/components/_docs-block.scss
index a2d695259d9c84954a6cd946a29054067539dc5e..eae2ff83ac07d6460de6f5e709f1ba4fb66c92c7 100644
--- a/opentech/static_src/src/sass/apply/components/_docs-block.scss
+++ b/opentech/static_src/src/sass/apply/components/_docs-block.scss
@@ -95,6 +95,12 @@
         font-weight: $weight--bold;
         margin-right: 1rem;
 
+        &:disabled,
+        &.is-disabled {
+            color: $color--mid-grey;
+            cursor: default;
+        }
+
         &:last-child {
             margin-right: 0;
         }
@@ -173,4 +179,8 @@
             margin-right: 0;
         }
     }
+
+    &__document-form {
+        display: inline;
+    }
 }
diff --git a/opentech/static_src/src/sass/apply/components/_error-bar.scss b/opentech/static_src/src/sass/apply/components/_error-bar.scss
new file mode 100644
index 0000000000000000000000000000000000000000..07465dce7bc77ec71a23ceeb7f59c173d4fd6858
--- /dev/null
+++ b/opentech/static_src/src/sass/apply/components/_error-bar.scss
@@ -0,0 +1,15 @@
+.error-bar {
+    padding: $mobile-gutter;
+    margin: 0 auto 2rem;
+    background: $color--tomato;
+    color: $color--white;
+
+    &__copy {
+        margin: 0 0 1rem;
+        font-weight: $weight--bold;
+
+        &:last-child {
+            margin: 0;
+        }
+    }
+}
diff --git a/opentech/static_src/src/sass/apply/components/_filters.scss b/opentech/static_src/src/sass/apply/components/_filters.scss
index f6018235d5ad2ecc37b175d9bb8225ac92c9f5c1..9a7f427b9ba3b0ee46246df3979a843e74c9606c 100644
--- a/opentech/static_src/src/sass/apply/components/_filters.scss
+++ b/opentech/static_src/src/sass/apply/components/_filters.scss
@@ -1,7 +1,7 @@
 .filters {
     display: none;
 
-    .filters-open & {
+    &.filters-open {
         position: fixed;
         top: 0;
         right: 0;
@@ -21,7 +21,7 @@
         transition-delay: $base-delay;
         pointer-events: none;
 
-        .filters-open & {
+        &.filters-open {
             position: relative;
             top: auto;
             left: auto;
diff --git a/opentech/static_src/src/sass/apply/components/_form.scss b/opentech/static_src/src/sass/apply/components/_form.scss
index 03e82401dcf523e979cd425081be221a0eb556a2..ca166d9d4360a96664a6082a35ea52de2de16359 100644
--- a/opentech/static_src/src/sass/apply/components/_form.scss
+++ b/opentech/static_src/src/sass/apply/components/_form.scss
@@ -418,6 +418,43 @@
             background-size: 12px;
             border: 1px solid $color--dark-blue;
         }
+
+        &.delete {
+            + label {
+                padding-left: 0;
+
+                &::before {
+                    display: none;
+                }
+
+                &::after {
+                    content: '';
+                    background: url('./../../images/cross.svg') left no-repeat;
+                    margin-left: 10px;
+                    width: 14px;
+                    height: 13px;
+                    display: inline-block;
+                }
+            }
+
+            &:checked + label {
+                text-decoration: line-through;
+                color: $color--black-60;
+
+                &::after {
+                    content: '(will be removed on save) - undo';
+                    background: none;
+                    color: $color--light-blue;
+                    font-size: 14px;
+                    position: absolute;
+                    font-weight: $weight--bold;
+                    width: auto;
+                    height: auto;
+                }
+            }
+
+
+        }
     }
 
     .errorlist {
diff --git a/opentech/static_src/src/sass/apply/components/_funding-block.scss b/opentech/static_src/src/sass/apply/components/_funding-block.scss
index 8f18deb78f16368a959a87e627cbe8759cc10567..969bdeca020ea40f79f203a8bb4085f5e58de13d 100644
--- a/opentech/static_src/src/sass/apply/components/_funding-block.scss
+++ b/opentech/static_src/src/sass/apply/components/_funding-block.scss
@@ -11,10 +11,6 @@
         flex-wrap: wrap;
     }
 
-    @include media-query(tablet-portrait) {
-        max-width: 610px;
-    }
-
     &__item {
         margin: 0 .5rem 1rem 0;
 
diff --git a/opentech/static_src/src/sass/apply/components/_grid.scss b/opentech/static_src/src/sass/apply/components/_grid.scss
index 23e966a9040b3dbf904e86753fd9c696db540877..06903cce6e6f322b29558cf3a5dc51d6b25d2606 100644
--- a/opentech/static_src/src/sass/apply/components/_grid.scss
+++ b/opentech/static_src/src/sass/apply/components/_grid.scss
@@ -53,6 +53,10 @@
                 grid-template-columns: 1fr 1fr;
             }
 
+            &.delete {
+                grid-template-columns: 1fr;
+            }
+
             .form--comments & {
                 margin: 20px 0 0;
                 grid-gap: 10px; // sass-lint:disable-line no-misspelled-properties
diff --git a/opentech/static_src/src/sass/apply/components/_invoice-block.scss b/opentech/static_src/src/sass/apply/components/_invoice-block.scss
index d0bc7af63f87d31c016c72d8b0e7566df6416a0e..273c8a953d9f6988994f42d7aac7098dac7dd1a3 100644
--- a/opentech/static_src/src/sass/apply/components/_invoice-block.scss
+++ b/opentech/static_src/src/sass/apply/components/_invoice-block.scss
@@ -9,10 +9,6 @@
         padding: 2rem;
     }
 
-    @include media-query(tablet-portrait) {
-        max-width: 610px;
-    }
-
     &__item {
         margin-bottom: 1rem;
 
diff --git a/opentech/static_src/src/sass/apply/components/_payment-block.scss b/opentech/static_src/src/sass/apply/components/_payment-block.scss
index 1cb21d1282882f263f601bfdaecb3f2b260936b3..3e2a5da4fb1682f97490875a66192a135e5aafab 100644
--- a/opentech/static_src/src/sass/apply/components/_payment-block.scss
+++ b/opentech/static_src/src/sass/apply/components/_payment-block.scss
@@ -7,10 +7,6 @@
         padding: 2rem;
     }
 
-    @include media-query(tablet-portrait) {
-        max-width: 610px;
-    }
-
     &__header {
         margin-bottom: 1rem;
 
@@ -47,10 +43,6 @@
         }
     }
 
-    &__status-link {
-        font-size: map-get($font-sizes, milli);
-    }
-
     &__rejected {
         text-align: center;
     }
@@ -83,7 +75,7 @@
                 padding: 10px;
 
                 @include media-query(tablet-landscape) {
-                    text-align: center;
+                    text-align: left;
                 }
             }
 
@@ -122,9 +114,11 @@
 
                 &:last-child {
                     padding: 0 0 1rem;
-
-                    @include media-query(tablet-landscape) {
-                        padding: 1rem;
+                    display: flex;
+                    flex-wrap: wrap;
+                    & > * {
+                        flex: 1 1 55px;
+                        max-width: 55px;
                     }
                 }
 
@@ -136,18 +130,18 @@
     }
 
     &__table-amount {
-        width: 20%;
+        width: 25%;
         min-width: 90px;
     }
 
     &__table-status {
         min-width: 160px;
-        width: 35%;
+        width: 25%;
     }
 
-    &__table-docs {
+    &__table-date {
         min-width: 180px;
-        width: 20%;
+        width: 25%;
     }
 
     &__table-update {
diff --git a/opentech/static_src/src/sass/apply/components/_simplified.scss b/opentech/static_src/src/sass/apply/components/_simplified.scss
new file mode 100644
index 0000000000000000000000000000000000000000..15b31cb1ef1b5e3b4d4c9b3bec8bb53fb9bdfc29
--- /dev/null
+++ b/opentech/static_src/src/sass/apply/components/_simplified.scss
@@ -0,0 +1,115 @@
+// class used for custom styling on the simplified submission and project pages
+.simplified {
+    &__admin-bar {
+        position: relative;
+        right: 50%;
+        left: 50%;
+        width: 100vw;
+        padding: $mobile-gutter;
+        margin-right: -50vw;
+        margin-left: -50vw;
+        color: $color--white;
+        background-color: $color--dark-grey;
+    }
+
+    &__admin-bar-inner {
+        max-width: $site-width;
+        margin: 0 auto;
+    }
+
+    &__projects-link {
+        display: inline-flex;
+        align-items: center;
+        color: $color--lightest-blue;
+        font-weight: $weight--bold;
+
+        &::before {
+            @include triangle(top, currentColor, 5px);
+            margin-right: .5rem;
+            transform: rotate(-90deg);
+        }
+    }
+
+    &__heading {
+        @include responsive-font-sizes(30px, map-get($font-sizes, beta));
+        letter-spacing: -.02rem;
+        font-weight: $weight--bold;
+        margin: 0;
+    }
+
+    &__subheading {
+        display: flex;
+        flex-wrap: wrap;
+        margin: 0;
+
+        @include media-query(tablet-portrait) {
+            margin-bottom: 1rem;
+        }
+
+        span {
+            &::after {
+                padding: 0 15px;
+                content: '|';
+            }
+
+            &:last-child {
+                &::after {
+                    padding: 0;
+                    content: '';
+                }
+            }
+        }
+    }
+
+    &__wrapper {
+        padding: $mobile-gutter 0;
+
+        @include media-query(tablet-portrait) {
+            padding: 3rem 0;
+        }
+    }
+
+    &__meta {
+        @include heading-text;
+        @include font-size(zeta);
+        display: flex;
+        flex-direction: column;
+        margin-top: $mobile-gutter;
+        font-weight: $weight--normal;
+        color: transparentize($color--dark-grey, .5);
+
+        @include media-query(tablet-landscape) {
+            flex-direction: row;
+            margin-top: 0;
+        }
+    }
+
+    &__meta-item {
+        margin-right: 15px;
+
+        &:last-child {
+            margin-right: 0;
+        }
+    }
+
+    &__rich-text {
+        margin-bottom: 2rem;
+        word-break: break-word;
+
+        > section {
+            margin: 0 0 1rem;
+
+            p:first-of-type {
+                margin-top: 0;
+            }
+
+            p:empty {
+                margin: 0;
+            }
+        }
+
+        h4 {
+            margin: 0;
+        }
+    }
+}
diff --git a/opentech/static_src/src/sass/apply/components/_status-block.scss b/opentech/static_src/src/sass/apply/components/_status-block.scss
index 2657866ac39c119d3e051a081eafa85c596c1f28..6f9e758d59b2e816b69b02aa59b62d10a65e2ff3 100644
--- a/opentech/static_src/src/sass/apply/components/_status-block.scss
+++ b/opentech/static_src/src/sass/apply/components/_status-block.scss
@@ -16,7 +16,7 @@
         @include media-query(tablet-landscape) {
             border-bottom: 0;
             border-right: 1px solid $color--light-mid-grey;
-            width: calc(100% / 9);
+            width: 100%;
             padding: 10px;
             display: flex;
             align-items: flex-start;
diff --git a/opentech/static_src/src/sass/apply/components/_tooltip.scss b/opentech/static_src/src/sass/apply/components/_tooltip.scss
new file mode 100644
index 0000000000000000000000000000000000000000..61af9c25ae8f87f33ec7d9e32a056f082d24bd2e
--- /dev/null
+++ b/opentech/static_src/src/sass/apply/components/_tooltip.scss
@@ -0,0 +1,30 @@
+[data-tooltip] {
+    position: relative;
+    z-index: 2;
+    cursor: pointer;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
+
+[data-tooltip]::before {
+    visibility: hidden;
+    opacity: 0;
+    pointer-events: none;
+    transition: opacity, bottom, $transition;
+    position: absolute;
+    bottom: 110%;
+    margin-bottom: 5px;
+    padding: 7px;
+    background-color: $color--dark-grey;
+    color: $color--white;
+    content: attr(data-tooltip);
+    text-align: center;
+    font-size: 14px;
+}
+
+[data-tooltip]:hover::before {
+    visibility: visible;
+    opacity: 1;
+    bottom: 130%;
+}
diff --git a/opentech/static_src/src/sass/apply/components/_traffic-light.scss b/opentech/static_src/src/sass/apply/components/_traffic-light.scss
index 21779a7aad61e379e6231aaee00cbbc431ed902c..7d28aebcd1524560b2ed9f5e7324694f4650388a 100644
--- a/opentech/static_src/src/sass/apply/components/_traffic-light.scss
+++ b/opentech/static_src/src/sass/apply/components/_traffic-light.scss
@@ -6,16 +6,19 @@
     text-align: center;
     border-radius: 50%;
 
+    &--no,
     &--red {
         color: $color--tomato;
         background-color: $color--tomato;
     }
 
+    &--maybe,
     &--amber {
         color: $color--mustard;
         background-color: $color--mustard;
     }
 
+    &--yes,
     &--green {
         color: $color--green;
         background-color: $color--green;
diff --git a/opentech/static_src/src/sass/apply/components/_wrapper.scss b/opentech/static_src/src/sass/apply/components/_wrapper.scss
index 1a6c227b6ef04de9ae45227eced7bca259d355cb..dfa413a509a54ac5fef19eab2616605f3ff72d83 100644
--- a/opentech/static_src/src/sass/apply/components/_wrapper.scss
+++ b/opentech/static_src/src/sass/apply/components/_wrapper.scss
@@ -178,6 +178,10 @@
                 padding-right: 20px;
             }
         }
+
+        .card:first-child {
+            margin-top: 20px;
+        }
     }
 
     &--search {
diff --git a/opentech/static_src/src/sass/apply/main.scss b/opentech/static_src/src/sass/apply/main.scss
index 3cb623c756ca2c22160742324637992d989b40ad..2846143b74327a9b989584fa52857fe741d4a99d 100644
--- a/opentech/static_src/src/sass/apply/main.scss
+++ b/opentech/static_src/src/sass/apply/main.scss
@@ -13,9 +13,11 @@
 @import 'components/admin-bar';
 @import 'components/activity-feed';
 @import 'components/actions-bar';
+@import 'components/card';
 @import 'components/comment';
 @import 'components/button';
 @import 'components/editor';
+@import 'components/error-bar';
 @import 'components/feed';
 @import 'components/filters';
 @import 'components/grid';
@@ -44,10 +46,12 @@
 @import 'components/reviews-sidebar';
 @import 'components/round-block';
 @import 'components/select2';
+@import 'components/simplified';
 @import 'components/status-block';
 @import 'components/submission-meta';
 @import 'components/revision';
 @import 'components/table';
+@import 'components/tooltip';
 @import 'components/traffic-light';
 @import 'components/wrapper';
 @import 'components/revisions';
diff --git a/requirements.txt b/requirements.txt
index d9cd302adf2df3ee70e16761fc94b2b5c0d1590f..86a1a0ca06c7aa2862217993dca879217e643af2 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -5,6 +5,7 @@
 # Test dependencies
 flake8
 factory_boy==2.9.2
+Faker==1.0.8 # Pinning to avoid API changes to pydecimal in 0.x - could go higher in future
 wagtail-factories==1.1.0
 responses==0.10.4
 
@@ -41,6 +42,7 @@ django==2.1.11
 gunicorn==19.9.0
 mailchimp3==3.0.9
 mistune==0.8.4
+more-itertools==7.2.0
 Pillow==5.4.1
 psycopg2==2.7.3.1
 social_auth_app_django==3.1.0