From cdb3518b28b1db5e88484bb84345555371a763b2 Mon Sep 17 00:00:00 2001 From: Yingtong Li Date: Sun, 5 Jan 2020 17:54:09 +1100 Subject: [PATCH] Import ABA export and claim payment --- selfserv/settings.example.py | 6 ++ sstreasury/aba.py | 65 +++++++++++++++++++ sstreasury/jinja2/sstreasury/base.html | 7 +- sstreasury/jinja2/sstreasury/budget_list.html | 4 +- sstreasury/jinja2/sstreasury/claim_list.html | 4 +- .../jinja2/sstreasury/claim_processing.html | 61 +++++++++++++++++ sstreasury/jinja2/sstreasury/claim_view.html | 6 ++ .../jinja2/sstreasury/email/claim_paid.md | 3 + sstreasury/models.py | 7 ++ sstreasury/urls.py | 1 + sstreasury/views.py | 47 +++++++++++++- 11 files changed, 204 insertions(+), 7 deletions(-) create mode 100644 sstreasury/aba.py create mode 100644 sstreasury/jinja2/sstreasury/claim_processing.html create mode 100644 sstreasury/jinja2/sstreasury/email/claim_paid.md diff --git a/selfserv/settings.example.py b/selfserv/settings.example.py index 13a8023..1aa4c52 100644 --- a/selfserv/settings.example.py +++ b/selfserv/settings.example.py @@ -32,6 +32,12 @@ PROMO_LOGO_URL = 'https://placehold.it/2000x500' PROMO_LOGO_LINK = 'https://example.com' PROMO_GROUPS_MANDATORY = ['All Years'] +ABA_USER_NAME = 'Society Name' +ABA_BANK_NAME = 'CBA' +ABA_BANK_CODE = 0 +ABA_SRC_BSB = '000-000' +ABA_SRC_ACC = '00000000' + # Application definition INSTALLED_APPS = [ diff --git a/sstreasury/aba.py b/sstreasury/aba.py new file mode 100644 index 0000000..db7a2d2 --- /dev/null +++ b/sstreasury/aba.py @@ -0,0 +1,65 @@ +# Society Self-Service +# Copyright © 2018–2020 Yingtong Li (RunasSudo) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +# https://www.brad-smith.info/blog/archives/405 +# https://www.cemtexaba.com/aba-format/cemtex-aba-file-format-details +# https://ddkonline.blogspot.com/2009/01/aba-bank-payment-file-format-australian.html + +def write_descriptive(f, reel_seq=1, bank_name='', user_name='', bank_code=0, description='', date=None): + if reel_seq < 0 or reel_seq > 99 or len(bank_name) > 3 or len(user_name) > 26 or bank_code < 0 or bank_code > 999999 or len(description) > 12: + raise Exception('Invalid ABA data') + + f.write(b'0') # Record Type 0 + f.write(b' ' * 17) # Blank + f.write('{:02}'.format(reel_seq).encode('ascii')) # Reel Sequence Number + f.write('{: <3}'.format(bank_name).encode('ascii')) # Financial Institution abbreviation + f.write(b' ' * 7) # Blank + f.write('{: <26}'.format(user_name).encode('ascii')) # User Preferred Specification + f.write('{:06}'.format(bank_code).encode('ascii')) # User Identification Number + f.write('{: <12}'.format(description).encode('ascii')) # Description + f.write(date.strftime('%d%m%y').encode('ascii')) # Date + f.write(b' ' * 40) # Blank + f.write(b'\r\n') + +def write_detail(f, dest_bsb='', dest_account='', indicator=' ', transaction_code=53, cents=0, dest_name='', reference='', src_bsb='', src_account='', src_name='', tax_withheld=0): + if len(dest_bsb.replace('-', '')) != 6 or len(dest_account) > 9 or len(indicator) != 1 or transaction_code < 0 or transaction_code > 99 or len(dest_name) > 32 or len(reference) > 18 or len(src_bsb.replace('-', '')) != 6 or len(src_account) > 9 or len(src_name) > 16: + raise Exception('Invalid ABA data') + + f.write(b'1') # Record Type 1 + f.write('{}-{}'.format(dest_bsb[:3], dest_bsb[-3:]).encode('ascii')) # BSB + f.write('{: >9}'.format(dest_account).encode('ascii')) # Account Number + f.write(indicator.encode('ascii')) # Indicator + f.write('{:02}'.format(transaction_code).encode('ascii')) # Transaction Code + f.write('{:010}'.format(cents).encode('ascii')) # Amount + f.write('{: <32}'.format(dest_name).encode('ascii')) # Title of Account + f.write('{: <18}'.format(reference).encode('ascii')) # Lodgement Reference + f.write('{}-{}'.format(src_bsb[:3], src_bsb[-3:]).encode('ascii')) # Trace BSB + f.write('{: >9}'.format(src_account).encode('ascii')) # Trace Account Number + f.write('{: <16}'.format(src_name).encode('ascii')) # Name of Remitter + f.write('{:08}'.format(tax_withheld).encode('ascii')) # Amount of Withholding Tax + f.write(b'\r\n') + +def write_total(f, credit_cents=0, num_detail_records=0): + f.write(b'7') # Record Type 7 + f.write(b'999-999') # BSB Format Filler + f.write(b' ' * 12) # Blank + f.write('{:010}'.format(credit_cents).encode('ascii')) # File (User) Net Total Amount + f.write('{:010}'.format(credit_cents).encode('ascii')) # File (User) Credit Total Amount + f.write(b'0' * 10) # File (User) Debit Total Amount + f.write(b' ' * 24) # Blank + f.write('{:06}'.format(num_detail_records).encode('ascii')) # File (user) count of Records Type 1 + f.write(b' ' * 40) # Blank + f.write(b'\r\n') diff --git a/sstreasury/jinja2/sstreasury/base.html b/sstreasury/jinja2/sstreasury/base.html index 83b3b36..c732892 100644 --- a/sstreasury/jinja2/sstreasury/base.html +++ b/sstreasury/jinja2/sstreasury/base.html @@ -2,7 +2,7 @@ {# Society Self-Service - Copyright © 2018 Yingtong Li (RunasSudo) + Copyright © 2018–2020 Yingtong Li (RunasSudo) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by @@ -34,7 +34,10 @@ Reimbursements diff --git a/sstreasury/jinja2/sstreasury/budget_list.html b/sstreasury/jinja2/sstreasury/budget_list.html index b46e00f..446679d 100644 --- a/sstreasury/jinja2/sstreasury/budget_list.html +++ b/sstreasury/jinja2/sstreasury/budget_list.html @@ -2,7 +2,7 @@ {# Society Self-Service - Copyright © 2018-2019 Yingtong Li (RunasSudo) + Copyright © 2018–2020 Yingtong Li (RunasSudo) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by @@ -24,7 +24,7 @@ - + diff --git a/sstreasury/jinja2/sstreasury/claim_list.html b/sstreasury/jinja2/sstreasury/claim_list.html index 92ff02c..91195c2 100644 --- a/sstreasury/jinja2/sstreasury/claim_list.html +++ b/sstreasury/jinja2/sstreasury/claim_list.html @@ -2,7 +2,7 @@ {# Society Self-Service - Copyright © 2018-2019 Yingtong Li (RunasSudo) + Copyright © 2018–2020 Yingtong Li (RunasSudo) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by @@ -24,7 +24,7 @@
NameName Status View
- + diff --git a/sstreasury/jinja2/sstreasury/claim_processing.html b/sstreasury/jinja2/sstreasury/claim_processing.html new file mode 100644 index 0000000..0086e60 --- /dev/null +++ b/sstreasury/jinja2/sstreasury/claim_processing.html @@ -0,0 +1,61 @@ +{% extends 'sstreasury/base.html' %} + +{# + Society Self-Service + Copyright © 2018–2020 Yingtong Li (RunasSudo) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +#} + +{% block title %}Claims processing{% endblock %} + +{% block maincontent %} +

Claims processing

+ +
+ + + +
NamePurpose Status View
+ + + + + + + + + + + {% for claim in claims %} + + + + + + + + {% endfor %} + +
PurposePayeeTotalView
{{ claim.purpose }}{{ claim.payee_name }}{{ '${:.2f}'.format(claim.get_total()) }} + +
+ + + +{% endblock %} + +{% block head %} + {{ super() }} +{% endblock %} diff --git a/sstreasury/jinja2/sstreasury/claim_view.html b/sstreasury/jinja2/sstreasury/claim_view.html index 5501358..937bffd 100644 --- a/sstreasury/jinja2/sstreasury/claim_view.html +++ b/sstreasury/jinja2/sstreasury/claim_view.html @@ -50,6 +50,12 @@ {% endif %} + {% if claim.state == import('sstreasury.models').ClaimState.APPROVED.value and request.user.groups.filter(name='Treasury').exists() %} +
+

This claim has been approved and is now awaiting payment. To pay this claim, access the Claims processing page.

+
+ {% endif %} + diff --git a/sstreasury/jinja2/sstreasury/email/claim_paid.md b/sstreasury/jinja2/sstreasury/email/claim_paid.md new file mode 100644 index 0000000..a76dbbc --- /dev/null +++ b/sstreasury/jinja2/sstreasury/email/claim_paid.md @@ -0,0 +1,3 @@ +Your reimbursement claim titled *{{ claim.purpose }}* (RE-{{ claim.id }}) has been paid. + +{{ baseurl }}{{ url('claim_view', kwargs={'id': claim.id}) }} diff --git a/sstreasury/models.py b/sstreasury/models.py index 222e00a..a633eef 100644 --- a/sstreasury/models.py +++ b/sstreasury/models.py @@ -20,6 +20,7 @@ from django.db import models from django.utils import timezone from jsonfield import JSONField +from decimal import Decimal from enum import Enum class DescriptionEnum(Enum): @@ -199,6 +200,12 @@ class ReimbursementClaim(models.Model): payee_bsb = models.CharField(max_length=7) payee_account = models.TextField(max_length=20) + def get_total(self): + total = Decimal(0) + for item in self.items: + total += Decimal(item['Unit price']) * item['Units'] + return total + def update_state(self, user, state): self.state = state.value self.save() diff --git a/sstreasury/urls.py b/sstreasury/urls.py index 9583951..629ef12 100644 --- a/sstreasury/urls.py +++ b/sstreasury/urls.py @@ -31,5 +31,6 @@ urlpatterns = [ path('claims/view//print', views.claim_print, name='claim_print'), path('claims/edit/', views.claim_edit, name='claim_edit'), path('claims/action/', views.claim_action, name='claim_action'), + path('claims/processing', views.claim_processing, name='claim_processing'), path('', views.index, name='treasury'), ] diff --git a/sstreasury/views.py b/sstreasury/views.py index 7e7f01d..e609edc 100644 --- a/sstreasury/views.py +++ b/sstreasury/views.py @@ -1,5 +1,5 @@ # Society Self-Service -# Copyright © 2018-2019 Yingtong Li (RunasSudo) +# Copyright © 2018–2020 Yingtong Li (RunasSudo) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by @@ -19,16 +19,20 @@ from django.contrib.auth.models import User from django.core.exceptions import PermissionDenied from django.core.validators import validate_email +from django.conf import settings from django.db import transaction +from django.http import HttpResponse from django.shortcuts import render, redirect from django.urls import reverse from django.utils import timezone from django.views import generic +from . import aba from . import models from ssmain.email import Emailer import functools +import io import itertools import json @@ -565,3 +569,44 @@ def claim_action(request, claim): emailer.send_mail([claim.author.email], 'Action required: Reimbursement claim returned for re-drafting: {} (RE-{})'.format(claim.purpose, claim.id), 'sstreasury/email/claim_returned.md', {'claim': claim}) return redirect(reverse('claim_view', kwargs={'id': claim.id})) + +@login_required +def claim_processing(request): + if not request.user.groups.filter(name='Treasury').exists(): + raise PermissionDenied + + if request.method == 'POST': + if request.POST['action'] == 'Export': + claims = models.ReimbursementClaim.objects.filter(state=models.ClaimState.APPROVED.value).all() + claims = [c for c in claims if request.POST.get('claim_{}'.format(c.id), False)] + + aba_file = io.BytesIO() + + aba.write_descriptive(aba_file, bank_name=settings.ABA_BANK_NAME, user_name=settings.ABA_USER_NAME, bank_code=settings.ABA_BANK_CODE, description='Reimburse', date=timezone.now()) + + for claim in claims: + aba.write_detail(aba_file, dest_bsb=claim.payee_bsb, dest_account=claim.payee_account, cents=claim.get_total()*100, dest_name=claim.payee_name, reference='RE-{}'.format(claim.id), src_bsb=settings.ABA_SRC_BSB, src_account=settings.ABA_SRC_ACC, src_name=settings.ABA_USER_NAME) + + aba.write_total(aba_file, credit_cents=sum(c.get_total() for c in claims)*100, num_detail_records=len(claims)) + aba_file.flush() + + response = HttpResponse(aba_file.getvalue(), content_type='text/plain') + response['Content-Disposition'] = 'attachment; filename="claims.aba"' + return response + + if request.POST['action'] == 'Pay': + claims = models.ReimbursementClaim.objects.filter(state=models.ClaimState.APPROVED.value).all() + claims = [c for c in claims if request.POST.get('claim_{}'.format(c.id), False)] + + for claim in claims: + with transaction.atomic(): + claim.update_state(request.user, models.ClaimState.PAID) + + emailer = Emailer() + emailer.send_mail([claim.author.email], 'Claim paid: {} (RE-{})'.format(claim.purpose, claim.id), 'sstreasury/email/claim_paid.md', {'claim': claim}) + + claims = models.ReimbursementClaim.objects.filter(state=models.ClaimState.APPROVED.value).all() + + return render(request, 'sstreasury/claim_processing.html', { + 'claims': claims + })