Import ABA export and claim payment

This commit is contained in:
Yingtong Li 2020-01-05 17:54:09 +11:00
parent 77102d7203
commit cdb3518b28
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
11 changed files with 204 additions and 7 deletions

View File

@ -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 = [

65
sstreasury/aba.py Normal file
View File

@ -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.gnu.org/licenses/>.
# 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')

View File

@ -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
<div class="menu">
<a class="{% if request.resolver_match.url_name == 'claim_list' %}active {% endif %}item" href="{{ url('claim_list') }}">Your reimbursement claims</a>
<a class="item">Create new claim</a>
<a class="{% if request.resolver_match.url_name == 'claim_new' %}active {% endif %}item" href="{{ url('claim_new') }}">Create new claim</a>
{% if request.user.groups.filter(name='Treasury').exists() %}
<a class="{% if request.resolver_match.url_name == 'claim_processing' %}active {% endif %}item" href="{{ url('claim_processing') }}">Claims processing</a>
{% endif %}
</div>
</div>
</div>

View File

@ -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 @@
<table class="ui selectable celled table">
<thead>
<tr>
<th class="twelve wide">Name</th>
<th class="eleven wide">Name</th>
<th class="four wide">Status</th>
<th class="one wide">View</th>
</tr>

View File

@ -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 @@
<table class="ui selectable celled table">
<thead>
<tr>
<th class="twelve wide">Name</th>
<th class="eleven wide">Purpose</th>
<th class="four wide">Status</th>
<th class="one wide">View</th>
</tr>

View File

@ -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 <https://www.gnu.org/licenses/>.
#}
{% block title %}Claims processing{% endblock %}
{% block maincontent %}
<h1>Claims processing</h1>
<form class="ui form" method="POST">
<button class="ui small primary labeled icon button" type="submit" name="action" value="Export"><i class="download icon"></i>Export selected claims to ABA</button>
<button class="ui small basic primary labeled icon button" type="submit" name="action" value="Pay"><i class="check icon"></i>Mark selected claims as paid</button>
<table class="ui celled table">
<thead>
<tr>
<th class="one wide"><input type="checkbox" onchange="$(this.form).find('.claim-checkbox').prop('checked', this.checked);"></th>
<th class="nine wide">Purpose</th>
<th class="three wide">Payee</th>
<th class="two wide">Total</th>
<th class="one wide">View</th>
</tr>
</thead>
<tbody>
{% for claim in claims %}
<tr>
<td><input name="claim_{{ claim.id }}" type="checkbox" class="claim-checkbox"></td>
<td>{{ claim.purpose }}</td>
<td>{{ claim.payee_name }}</td>
<td>{{ '${:.2f}'.format(claim.get_total()) }}</td>
<td>
<a href="{{ url('claim_view', kwargs={'id': claim.id}) }}" class="ui tiny primary icon button"><i class="eye icon"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
</form>
{% endblock %}
{% block head %}
{{ super() }}
{% endblock %}

View File

@ -50,6 +50,12 @@
</div>
{% endif %}
{% if claim.state == import('sstreasury.models').ClaimState.APPROVED.value and request.user.groups.filter(name='Treasury').exists() %}
<div class="ui message">
<p>This claim has been approved and is now awaiting payment. To pay this claim, access the <a href="{{ url('claim_processing') }}">Claims processing</a> page.</p>
</div>
{% endif %}
<input type="hidden" name="action" value="">
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
</form>

View File

@ -0,0 +1,3 @@
Your reimbursement claim titled *{{ claim.purpose }}* (RE-{{ claim.id }}) has been paid.
{{ baseurl }}{{ url('claim_view', kwargs={'id': claim.id}) }}

View File

@ -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()

View File

@ -31,5 +31,6 @@ urlpatterns = [
path('claims/view/<int:id>/print', views.claim_print, name='claim_print'),
path('claims/edit/<int:id>', views.claim_edit, name='claim_edit'),
path('claims/action/<int:id>', views.claim_action, name='claim_action'),
path('claims/processing', views.claim_processing, name='claim_processing'),
path('', views.index, name='treasury'),
]

View File

@ -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
})