source: main/waeup.aaue/trunk/src/waeup/aaue/etranzact/browser.py @ 14362

Last change on this file since 14362 was 14234, checked in by Henrik Bettermann, 8 years ago

Ad Stuent ID card payments.

  • Property svn:keywords set to Id
File size: 18.0 KB
Line 
1## $Id: browser.py 14234 2016-10-27 14:20:09Z henrik $
2##
3## Copyright (C) 2012 Uli Fouquet & Henrik Bettermann
4## This program is free software; you can redistribute it and/or modify
5## it under the terms of the GNU General Public License as published by
6## the Free Software Foundation; either version 2 of the License, or
7## (at your option) any later version.
8##
9## This program is distributed in the hope that it will be useful,
10## but WITHOUT ANY WARRANTY; without even the implied warranty of
11## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12## GNU General Public License for more details.
13##
14## You should have received a copy of the GNU General Public License
15## along with this program; if not, write to the Free Software
16## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
17##
18from datetime import datetime
19import httplib
20import urllib
21import urllib2
22import re
23from xml.dom.minidom import parseString
24import grok
25from zope.component import getUtility
26from zope.catalog.interfaces import ICatalog
27from waeup.kofa.interfaces import IUniversity, CLEARED
28from waeup.kofa.payments.interfaces import IPayer
29from waeup.kofa.webservices import PaymentDataWebservice
30from waeup.kofa.browser.layout import KofaPage, UtilityView
31from waeup.kofa.students.viewlets import ApprovePaymentActionButton as APABStudent
32from waeup.kofa.applicants.viewlets import ApprovePaymentActionButton as APABApplicant
33from waeup.aaue.interfaces import academic_sessions_vocab
34from kofacustom.nigeria.interswitch.browser import (
35    InterswitchActionButtonStudent,
36    InterswitchRequestWebserviceActionButtonStudent,
37    InterswitchActionButtonApplicant,
38    InterswitchRequestWebserviceActionButtonApplicant)
39from waeup.aaue.interfaces import MessageFactory as _
40from waeup.aaue.students.interfaces import ICustomStudentOnlinePayment
41from waeup.aaue.applicants.interfaces import ICustomApplicantOnlinePayment
42
43ERROR_PART1 = (
44        'PayeeName=N/A~'
45        + 'Faculty=N/A~'
46        + 'Department=N/A~'
47        + 'Level=N/A~'
48        + 'ProgrammeType=N/A~'
49        + 'StudyType=N/A~'
50        + 'Session=N/A~'
51        + 'PayeeID=N/A~'
52        + 'Amount=N/A~'
53        + 'FeeStatus=')
54ERROR_PART2 = (
55        '~Semester=N/A~'
56        + 'PaymentType=N/A~'
57        + 'MatricNumber=N/A~'
58        + 'Email=N/A~'
59        + 'PhoneNumber=N/A')
60
61class CustomPaymentDataWebservice(PaymentDataWebservice):
62    """A simple webservice to publish payment and payer details on request from
63    accepted IP addresses without authentication.
64
65    Etranzact is asking for the PAYEE_ID which is indeed misleading.
66    These are not the data of the payee but of the payer. And it's
67    not the id of the payer but of the payment.
68    """
69    grok.name('feerequest')
70
71    #ACCEPTED_IP = ('195.219.3.181', '195.219.3.184')
72    ACCEPTED_IP = None
73
74    def update(self, PAYEE_ID=None, PAYMENT_TYPE=None):
75        if PAYEE_ID == None:
76            self.output = ERROR_PART1 + 'Missing PAYEE_ID' + ERROR_PART2
77            return
78        real_ip = self.request.get('HTTP_X_FORWARDED_FOR', None)
79        # We can forego the logging once eTranzact payments run smoothly
80        # and the accepted IP addresses are used.
81        if real_ip:
82            self.context.logger.info('PaymentDataWebservice called: %s' % real_ip)
83        if real_ip  and self.ACCEPTED_IP:
84            if real_ip not in  self.ACCEPTED_IP:
85                self.output = ERROR_PART1 + 'Wrong IP address' + ERROR_PART2
86                return
87        category_mapping = {
88            'SCHOOL-FEE-NEW': ('schoolfee',),
89            'SCHOOL-FEE-RETURNING': ('schoolfee',),
90            'SCHOOL-FEE-PLUS-NEW': ('schoolfee_incl',),
91            'SCHOOL-FEE-PLUS-RETURNING': ('schoolfee_incl',),
92            'SCHOOL-FEE-PG-NEW': ('schoolfee',),
93            'SCHOOL-FEE-PG-RETURNING': ('schoolfee',),
94            'SCHOOL-FEE-FIRST-INSTALMENT-PLUS': ('schoolfee_1',),
95            'SCHOOL-FEE-SECOND-INSTALMENT': ('schoolfee_2',),
96            'SCHOOL-FEE-BALANCE': ('schoolfee','schoolfee_incl',
97                                   'schoolfee_1','schoolfee_2'),
98            'SCHOOL-FEE-PT-NEW': ('schoolfee',),
99            'SCHOOL-FEE-PT-RETURNING': ('schoolfee',),
100            'SCHOOL-FEE-PT-PLUS-NEW': ('schoolfee_incl',),
101            'SCHOOL-FEE-PT-PLUS-RETURNING': ('schoolfee_incl',),
102            'SCHOOL-FEE-PT-PG-NEW': ('schoolfee',),
103            'SCHOOL-FEE-PT-PG-RETURNING': ('schoolfee',),
104            'SCHOOL-FEE-PT-FIRST-INSTALMENT-PLUS': ('schoolfee_1',),
105            'SCHOOL-FEE-PT-SECOND-INSTALMENT': ('schoolfee_2',),
106            'SCHOOL-FEE-PT-BALANCE': ('schoolfee','schoolfee_incl',
107                                      'schoolfee_1','schoolfee_2'),
108            'SCHOOL-FEE-FP-NEW': ('schoolfee',),
109
110            'ACCEPTANCE-FEE': ('clearance',),
111            'ACCEPTANCE-FEE-PLUS': ('clearance_incl',),
112            'ACCEPTANCE-FEE-PG': ('clearance',),
113            'ACCEPTANCE-FEE-PT': ('clearance',),
114            'ACCEPTANCE-FEE-PT-PLUS': ('clearance_incl',),
115            'ACCEPTANCE-FEE-PT-PG': ('clearance',),
116            'ACCEPTANCE-FEE-FP': ('clearance',),
117
118            'APPLICATION-FEE': ('application',),
119            'APPLICATION-FEE-PT': ('application',),
120            'APPLICATION-FEE-FP': ('application',),
121
122            'LATE-REGISTRATION': ('late_registration',),
123            'LATE-REGISTRATION-PT': ('late_registration',),
124
125            'AAU-STUDENT-WELFARE-ASSURANCE': ('welfare',),
126            'AAU-STUDENT-WELFARE-ASSURANCE-PT': ('welfare',),
127
128            'AAU-STUDENT-ID_CARD': ('id_card',),
129
130            'HOSTEL-ACCOMMODATION-FEE': ('hostel_maintenance',),
131            'HOSTEL-ACCOMMODATION-FEE-PT': ('hostel_maintenance',),
132
133            'AAU-LAPEL-FILE-FEE': ('lapel',),
134            'AAU-LAPEL-FILE-FEE-PT': ('lapel',),
135
136            'MATRICULATION-GOWN-FEE': ('matric_gown',),
137            'MATRICULATION-GOWN-FEE-PT': ('matric_gown',),
138
139            'CONCESSIONAL-FEE': ('concessional',),
140            'CONCESSIONAL-FEE-PT': ('concessional',),
141
142            'STUDENTS-UNION-DUES': ('union',),
143            'STUDENTS-UNION-DUES-PT': ('union',),
144            }
145
146        if PAYMENT_TYPE not in category_mapping.keys():
147            self.output = ERROR_PART1 + 'Invalid PAYMENT_TYPE' + ERROR_PART2
148            return
149
150        # It seems eTranzact sends a POST request with an empty body but the URL
151        # contains a query string. So it's actually a GET request pretended
152        # to be a POST request. Although this does not comply with the
153        # RFC 2616 HTTP guidelines we may try to fetch the id from the QUERY_STRING
154        # value of the request.
155        #if PAYEE_ID is None:
156        #    try:
157        #        PAYEE_ID = self.request['QUERY_STRING'].split('=')[1]
158        #    except:
159        #        self.output = '-4'
160        #        return
161
162        cat = getUtility(ICatalog, name='payments_catalog')
163        results = list(cat.searchResults(p_id=(PAYEE_ID, PAYEE_ID)))
164        if len(results) != 1:
165            self.output = ERROR_PART1 + 'Invalid PAYEE_ID' + ERROR_PART2
166            return
167        student = getattr(results[0], 'student', None)
168        amount = results[0].amount_auth
169        payment_type = results[0].category
170        p_category = results[0].p_category
171        programme_type = results[0].p_item
172        if not programme_type:
173            programme_type = 'N/A'
174        academic_session = academic_sessions_vocab.getTerm(
175            results[0].p_session).title
176        status = results[0].p_state
177
178        if status == 'paid':
179            self.output = ERROR_PART1 + 'PAYEE_ID already used' + ERROR_PART2
180            return
181        if p_category not in category_mapping[PAYMENT_TYPE]:
182            self.output = ERROR_PART1 + 'Wrong PAYMENT_TYPE' + ERROR_PART2
183            return
184        if student and PAYMENT_TYPE.endswith('-RETURNING') \
185            and student.state == CLEARED:
186            self.output = ERROR_PART1 + 'Not a returning student' + ERROR_PART2
187            return
188        if student and PAYMENT_TYPE.endswith('-NEW') \
189            and student.state != CLEARED:
190            self.output = ERROR_PART1 + 'Not a new student' + ERROR_PART2
191            return
192        if student and '-PG' in PAYMENT_TYPE and not student.is_postgrad:
193            self.output = ERROR_PART1 + 'Not a postgrad student' + ERROR_PART2
194            return
195        if student and '-PG' not in PAYMENT_TYPE and student.is_postgrad \
196            and results[0].p_item != 'Balance':
197            self.output = ERROR_PART1 + 'Postgrad student' + ERROR_PART2
198            return
199        if student and '-PT' in PAYMENT_TYPE \
200            and not student.current_mode.endswith('_pt'):
201            self.output = ERROR_PART1 + 'Not a part-time student' + ERROR_PART2
202            return
203        if student and '-PT' not in PAYMENT_TYPE \
204            and student.current_mode.endswith('_pt'):
205            self.output = ERROR_PART1 + 'Part-time student' + ERROR_PART2
206            return
207        if student and '-FP' in PAYMENT_TYPE and student.current_mode != 'found':
208            self.output = ERROR_PART1 + 'Not a foundation programme student' + ERROR_PART2
209            return
210        if student and '-FP' not in PAYMENT_TYPE and student.current_mode == 'found':
211            self.output = ERROR_PART1 + 'Foundation programme student' + ERROR_PART2
212            return
213        if '-BALANCE' in PAYMENT_TYPE and results[0].p_item != 'Balance':
214            self.output = ERROR_PART1 + 'Not a balance payment' + ERROR_PART2
215            return
216        if not '-BALANCE' in PAYMENT_TYPE and results[0].p_item == 'Balance':
217            self.output = ERROR_PART1 + 'Balance payment' + ERROR_PART2
218            return
219
220        try:
221            owner = IPayer(results[0])
222            full_name = owner.display_fullname
223            matric_no = owner.id
224            faculty = owner.faculty
225            department = owner.department
226            study_type = owner.current_mode
227            email = owner.email
228            phone = owner.phone
229            level = owner.current_level
230        except (TypeError, AttributeError):
231            self.output = ERROR_PART1 +  'Unknown error' + ERROR_PART2
232            return
233        self.output = (
234            'PayeeName=%s~' +
235            'Faculty=%s~' +
236            'Department=%s~' +
237            'Level=%s~' +
238            'ProgrammeType=%s~' +
239            'StudyType=%s~' +
240            'Session=%s~' +
241            'PayeeID=%s~' +
242            'Amount=%s~' +
243            'FeeStatus=%s~' +
244            'Semester=N/A~' +
245            'PaymentType=%s~' +
246            'MatricNumber=%s~' +
247            'Email=%s~' +
248            'PhoneNumber=%s'
249
250            ) % (full_name, faculty,
251            department, level, programme_type, study_type,
252            academic_session, PAYEE_ID, amount, status, payment_type,
253            matric_no, email, phone)
254        return
255
256
257# Requerying eTranzact payments
258
259TERMINAL_ID = '0570000070'
260QUERY_URL =   'https://www.etranzact.net/WebConnectPlus/query.jsp'
261
262# Test environment
263#QUERY_URL =   'http://demo.etranzact.com:8080/WebConnect/queryPayoutletTransaction.jsp'
264#TERMINAL_ID = '5009892289'
265
266def query_etranzact(confirmation_number, payment):
267   
268    postdict = {}
269    postdict['TERMINAL_ID'] = TERMINAL_ID
270    #postdict['RESPONSE_URL'] = 'http://dummy'
271    postdict['CONFIRMATION_NO'] = confirmation_number
272    data = urllib.urlencode(postdict)
273    payment.conf_number = confirmation_number
274    try:
275        # eTranzact only accepts HTTP 1.1 requests. Therefore
276        # the urllib2 package is required here.
277        f = urllib2.urlopen(url=QUERY_URL, data=data)
278        success = f.read()
279        success = success.replace('\r\n','')
280        # eTranzact sends strange HTML tags which must be removed.
281        success = re.sub("<.*?>", "", success)
282        if 'CUSTOMER_ID' not in success:
283            msg = _('Invalid or unsuccessful callback: ${a}',
284                mapping = {'a': success})
285            log = 'invalid callback for payment %s: %s' % (payment.p_id, success)
286            payment.p_state = 'failed'
287            return False, msg, log
288        success = success.replace('%20',' ').split('&')
289        # We expect at least two parameters
290        if len(success) < 2:
291            msg = _('Invalid callback: ${a}', mapping = {'a': success})
292            log = 'invalid callback for payment %s: %s' % (payment.p_id, success)
293            payment.p_state = 'failed'
294            return False, msg, log
295        try:
296            success_dict = dict([tuple(i.split('=')) for i in success])
297        except ValueError:
298            msg = _('Invalid callback: ${a}', mapping = {'a': success})
299            log = 'invalid callback for payment %s: %s' % (payment.p_id, success)
300            payment.p_state = 'failed'
301            return False, msg, log
302    except IOError:
303        msg = _('eTranzact IOError')
304        log = 'eTranzact IOError'
305        return False, msg, log
306    payment.r_code = u'ET'
307    payment.r_company = u'etranzact'
308    payment.r_desc = u'%s' % success_dict.get('TRANS_DESCR')
309    payment.r_amount_approved = float(success_dict.get('TRANS_AMOUNT',0.0))
310    payment.r_card_num = None
311    payment.r_pay_reference = u'%s' % success_dict.get('RECEIPT_NO')
312    if payment.r_amount_approved != payment.amount_auth:
313        msg = _('Wrong amount')
314        log = 'wrong callback for payment %s: %s' % (payment.p_id, success)
315        payment.p_state = 'failed'
316        return False, msg, log
317    customer_id = success_dict.get('CUSTOMER_ID')
318    if payment.p_id != customer_id:
319        msg = _('Wrong payment id')
320        log = 'wrong callback for payment %s: %s' % (payment.p_id, success)
321        payment.p_state = 'failed'
322        return False, msg, log
323    log = 'valid callback for payment %s: %s' % (payment.p_id, success)
324    msg = _('Successful callback received')
325    payment.p_state = 'paid'
326    payment.payment_date = datetime.utcnow()
327    return True, msg, log
328
329class EtranzactEnterPinActionButtonApplicant(APABApplicant):
330    grok.context(ICustomApplicantOnlinePayment)
331    grok.require('waeup.payApplicant')
332    grok.order(3)
333    icon = 'actionicon_call.png'
334    text = _('Query eTranzact History')
335    target = 'enterpin'
336
337class EtranzactEnterPinActionButtonStudent(APABStudent):
338    grok.context(ICustomStudentOnlinePayment)
339    grok.require('waeup.payStudent')
340    grok.order(3)
341    icon = 'actionicon_call.png'
342    text = _('Query eTranzact History')
343    target = 'enterpin'
344
345class EtranzactEnterPinPageStudent(KofaPage):
346    """
347    """
348    grok.context(ICustomStudentOnlinePayment)
349    grok.name('enterpin')
350    grok.template('enterpin')
351    grok.require('waeup.payStudent')
352
353    buttonname = _('Submit to eTranzact')
354    label = _('Requery eTranzact History')
355    action = 'query_history'
356    placeholder = _('Confirmation Number (PIN)')
357
358    def update(self):
359        super(EtranzactEnterPinPageStudent, self).update()
360        if not self.context.p_category.startswith('schoolfee'):
361            return
362        student = self.context.student
363        if student.state != CLEARED:
364            return
365        if student.entry_session < 2013:
366            return
367        for ticket in student['payments'].values():
368            if ticket.p_state == 'paid' and \
369                ticket.p_category.startswith('clearance'):
370                return
371        self.flash(_('Please pay acceptance fee first.'), type="danger")
372        self.redirect(self.url(self.context, '@@index'))
373        return
374
375class EtranzactEnterPinPageApplicant(EtranzactEnterPinPageStudent):
376    """
377    """
378    grok.require('waeup.payApplicant')
379    grok.context(ICustomApplicantOnlinePayment)
380
381class EtranzactQueryHistoryPageStudent(UtilityView, grok.View):
382    """ Query history of eTranzact payments
383    """
384    grok.context(ICustomStudentOnlinePayment)
385    grok.name('query_history')
386    grok.require('waeup.payStudent')
387
388    def update(self, confirmation_number=None):
389        if self.context.p_state == 'paid':
390            self.flash(_('This ticket has already been paid.'))
391            return
392        student = self.context.student
393        success, msg, log = query_etranzact(confirmation_number,self.context)
394        student.writeLogMessage(self, log)
395        if not success:
396            self.flash(msg)
397            return
398        flashtype, msg, log = self.context.doAfterStudentPayment()
399        if log is not None:
400            student.writeLogMessage(self, log)
401        self.flash(msg, type=flashtype)
402        return
403
404    def render(self):
405        self.redirect(self.url(self.context, '@@index'))
406        return
407
408class EtranzactQueryHistoryPageApplicant(UtilityView, grok.View):
409    """ Query history of eTranzact payments
410    """
411    grok.context(ICustomApplicantOnlinePayment)
412    grok.name('query_history')
413    grok.require('waeup.payApplicant')
414
415    def update(self, confirmation_number=None):
416        ob_class = self.__implemented__.__name__
417        if self.context.p_state == 'paid':
418            self.flash(_('This ticket has already been paid.'))
419            return
420        applicant = self.context.__parent__
421        success, msg, log = query_etranzact(confirmation_number,self.context)
422        applicant.writeLogMessage(self, log)
423        if not success:
424            self.flash(msg)
425            return
426        flashtype, msg, log = self.context.doAfterApplicantPayment()
427        if log is not None:
428            applicant.writeLogMessage(self, log)
429        self.flash(msg, type=flashtype)
430        return
431
432    def render(self):
433        self.redirect(self.url(self.context, '@@index'))
434        return
435
436# Disable Interswitch viewlets. This could be avoided by defining the
437# action button viewlets of kofacustom.nigeria.interswitch.browser in the
438# context of INigeriaStudentOnlinePayment or INigeriaApplicantOnlinePayment
439# respectively. But then all interswitch.browser modules have to be extended.
440
441#class InterswitchActionButtonStudent(InterswitchActionButtonStudent):
442
443#    @property
444#    def target_url(self):
445#        return ''
446
447#class InterswitchRequestWebserviceActionButtonStudent(
448#    InterswitchRequestWebserviceActionButtonStudent):
449
450#    @property
451#    def target_url(self):
452#        return ''
453
454#class InterswitchActionButtonApplicant(InterswitchActionButtonApplicant):
455
456#    @property
457#    def target_url(self):
458#        return ''
459
460#class InterswitchRequestWebserviceActionButtonApplicant(
461#    InterswitchRequestWebserviceActionButtonApplicant):
462
463#    @property
464#    def target_url(self):
465#        return ''
Note: See TracBrowser for help on using the repository browser.