Index: /main/kofacustom.nigeria/trunk/etc/paypal-testing.conf-sample
===================================================================
--- /main/kofacustom.nigeria/trunk/etc/paypal-testing.conf-sample	(revision 17242)
+++ /main/kofacustom.nigeria/trunk/etc/paypal-testing.conf-sample	(revision 17242)
@@ -0,0 +1,9 @@
+# paypal credentials
+
+[rest-client]
+# id and secret you got from PayPal (long hex strings)
+id = invalid
+secret = invalid
+
+# valid values: sandbox, live
+mode = sandbox
Index: /main/kofacustom.nigeria/trunk/etc/site.zcml.in
===================================================================
--- /main/kofacustom.nigeria/trunk/etc/site.zcml.in	(revision 17241)
+++ /main/kofacustom.nigeria/trunk/etc/site.zcml.in	(revision 17242)
@@ -1,4 +1,5 @@
 <configure xmlns="http://namespaces.zope.org/zope"
            xmlns:kofa="http://namespaces.waeup.org/kofa"
+           xmlns:kofacustomng="http://namespaces.waeup.org/kofacustomng"
            i18n_domain="${kofa_params:devel_pkg}">
 
@@ -27,4 +28,8 @@
   <kofa:datacenter
       path="${buildout:directory}/var/datacenter" />
+
+  <!-- Where can the paypal config be found -->
+  <kofacustomng:paypalconf
+      path="${buildout:directory}/etc/paypal.conf" />
 
     <configure i18n_domain="${kofa_params:devel_pkg}">
Index: /main/kofacustom.nigeria/trunk/setup.py
===================================================================
--- /main/kofacustom.nigeria/trunk/setup.py	(revision 17241)
+++ /main/kofacustom.nigeria/trunk/setup.py	(revision 17242)
@@ -8,4 +8,5 @@
     'grok',
     'waeup.kofa >= 1.3.2',
+    'requests < 2.28',  # py3-only afterwards
     ],
 
Index: /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/configure.zcml
===================================================================
--- /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/configure.zcml	(revision 17241)
+++ /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/configure.zcml	(revision 17242)
@@ -6,4 +6,5 @@
   -->
   <include package="waeup.kofa" />
+  <include package="kofacustom.nigeria" file="meta.zcml" />
   <include package="kofacustom.nigeria" file="locales.zcml" />
   <grok:grok package="." />
Index: /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/ftesting.zcml
===================================================================
--- /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/ftesting.zcml	(revision 17241)
+++ /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/ftesting.zcml	(revision 17242)
@@ -2,4 +2,5 @@
    xmlns="http://namespaces.zope.org/zope"
    xmlns:kofa="http://namespaces.waeup.org/kofa"
+   xmlns:kofacustomng="http://namespaces.waeup.org/kofacustomng"
    i18n_domain="kofacustom.nigeria"
    package="kofacustom.nigeria"
@@ -17,4 +18,8 @@
   <kofa:datacenter
       path="../../../parts/test/datacenter" />
+
+  <!-- Where can the paypal config be found -->
+  <kofacustomng:paypalconf
+      path="../../../etc/paypal-testing.conf" />
 
 
Index: /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/interfaces.py
===================================================================
--- /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/interfaces.py	(revision 17241)
+++ /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/interfaces.py	(revision 17242)
@@ -79,5 +79,5 @@
     )
 
-# Define a validation method for JAMB reg numbers 
+# Define a validation method for JAMB reg numbers
 class NotJAMBRegNumber(schema.ValidationError):
     __doc__ = u"Invalid JAMB registration number"
@@ -156,4 +156,10 @@
         )
 
+    paypal_enabled = schema.Bool(
+        title = _(u'Paypal integration enabled'),
+        default = False,
+        )
+
+
 class ICustomSessionConfigurationAdd(ICustomSessionConfiguration):
     """A session configuration object in add mode.
Index: /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/meta.zcml
===================================================================
--- /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/meta.zcml	(revision 17242)
+++ /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/meta.zcml	(revision 17242)
@@ -0,0 +1,19 @@
+<configure
+    xmlns="http://namespaces.zope.org/zope"
+    xmlns:meta="http://namespaces.zope.org/meta"
+    xmlns:grok="http://namespaces.zope.org/grok">
+
+  <include package="grok" file="meta.zcml" />
+  <!-- include package="z3c.autoinclude" file="meta.zcml" / -->
+
+  <!-- Load the grokkers -->
+  <!-- grok:grok package=".meta" / -->
+
+  <meta:directive
+      namespace="http://namespaces.waeup.org/kofacustomng"
+      name="paypalconf"
+      schema=".zcml.IPaypalConfig"
+      handler=".zcml.paypal_conf"
+      />
+
+</configure>
Index: /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/payments/interfaces.py
===================================================================
--- /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/payments/interfaces.py	(revision 17241)
+++ /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/payments/interfaces.py	(revision 17242)
@@ -80,4 +80,5 @@
             (_('Etranzact'), 'etranzact'),
             (_('Remita'), 'remita'),
+            (_('Paypal'), 'paypal'),
             (_('Scratch Card'), 'sc'),
             (_('Manifest'), 'manifest'),
@@ -85,4 +86,11 @@
             (_('unknown'), 'unknown'),
             )
+        )
+
+    r_payment_link = schema.TextLine(
+        title = _(u'Response Payment Authorization Link'),
+        default = None,
+        required = False,
+        readonly = False,
         )
 
Index: /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/paypal/__init__.py
===================================================================
--- /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/paypal/__init__.py	(revision 17242)
+++ /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/paypal/__init__.py	(revision 17242)
@@ -0,0 +1,71 @@
+## $Id$
+##
+## Copyright (C) 2022 Uli Fouquet & Henrik Bettermann
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published by
+## the Free Software Foundation; either version 2 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 General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with this program; if not, write to the Free Software
+## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+##
+# Classes that represent entitites needed when dealing with paypal.
+import datetime
+import grok
+from zope.component import queryUtility
+from kofacustom.nigeria.paypal.interfaces import IPaypalConfig
+
+
+def module_activated(session, payment):
+    if queryUtility(IPaypalConfig) is None:
+        return False
+    if getattr(payment, 'r_company', '') and payment.r_company != 'paypal':
+        return False
+    config = grok.getSite()['configuration']
+    session_conf = config.get(str(session)) or config.get(str(
+        datetime.datetime.now().year))
+    return getattr(session_conf, "paypal_enabled", False)
+
+
+class Amount(object):
+    """Paypal Amount
+
+    A structure that represents amounts of money.
+    """
+
+    def __init__(self, value="0.00", currency_code="USD"):
+        if type(value) in (int, float):
+            value = "%.2f" % round(value, 2)
+        self.value = value
+        self.currency_code = currency_code
+
+    def json(self):
+        return dict(value=self.value, currency_code=self.currency_code)
+
+
+class PurchaseUnit(object):
+    """Paypal purchase unit.
+
+    When creating paypal orders, the single things to be paid are passed
+    formatted as `purchase units`.
+    """
+
+    def __init__(self, description="", amount=Amount(), custom_id=None):
+        self.description = description
+        self.amount = amount
+        self.custom_id = custom_id
+
+    def json(self):
+        result = dict(
+            amount=self.amount.json(),
+            description=self.description,
+        )
+        if self.custom_id is not None:
+            result["custom_id"] = self.custom_id
+        return result
Index: /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/paypal/applicantsbrowser.py
===================================================================
--- /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/paypal/applicantsbrowser.py	(revision 17242)
+++ /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/paypal/applicantsbrowser.py	(revision 17242)
@@ -0,0 +1,214 @@
+## $Id: browser.py 14759 2017-08-03 09:09:54Z henrik $
+##
+## Copyright (C) 2017 Uli Fouquet & Henrik Bettermann
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published by
+## the Free Software Foundation; either version 2 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 General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with this program; if not, write to the Free Software
+## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+##
+import grok
+import hashlib
+from datetime import datetime, timedelta
+from zope.component import getUtility, queryUtility
+from zope.security import checkPermission
+from waeup.kofa.interfaces import IKofaUtils
+from waeup.kofa.utils.helpers import to_timezone
+from waeup.kofa.browser.layout import UtilityView, KofaPage
+from waeup.kofa.browser.viewlets import ManageActionButton
+from waeup.kofa.applicants.browser import OnlinePaymentDisplayFormPage as OPDPApplicant
+#from kofacustom.nigeria.remita.helpers import (
+#    get_JSON_POST_response, query_remita, write_payments_log)
+from kofacustom.nigeria.payments.interfaces import INigeriaOnlinePayment
+from kofacustom.nigeria.applicants.interfaces import INigeriaApplicantOnlinePayment
+from kofacustom.nigeria.interfaces import MessageFactory as _
+from kofacustom.nigeria.paypal import module_activated, Amount, PurchaseUnit
+from kofacustom.nigeria.paypal.interfaces import IPaypalConfig
+from kofacustom.nigeria.paypal.rest import create_order, capture_order
+
+
+# grok.templatedir('browser_templates')
+
+
+# Buttons
+
+
+class PaypalActionButtonApplicant(ManageActionButton):
+    grok.order(1)
+    grok.context(INigeriaOnlinePayment)
+    grok.view(OPDPApplicant)
+    grok.require('waeup.payApplicant')
+    icon = 'actionicon_pay.png'
+    text = _('Pay via Paypal')
+    target = 'goto_paypal'
+
+    @property
+    def target_url(self):
+        # import pdb; pdb.set_trace()
+        if not module_activated(
+            self.context.__parent__.__parent__.year, self.context):
+            return ''
+        if self.context.p_state != 'unpaid':
+            return ''
+        return self.view.url(self.view.context, self.target)
+
+
+class PaypalRequestPaymentStatusActionButtonApplicant(ManageActionButton):
+    grok.order(2)
+    grok.context(INigeriaOnlinePayment)
+    grok.view(OPDPApplicant)
+    grok.require('waeup.payApplicant')
+    icon = 'actionicon_call.png'
+    text = _('Requery Paypal Payment Status')
+    target = 'capture_paypal_payment'
+
+    @property
+    def target_url(self):
+        if not module_activated(
+            self.context.__parent__.__parent__.year, self.context):
+            return ''
+        if self.context.p_state in ('paid', 'waived', 'scholarship'):
+            return ''
+        return self.view.url(self.view.context, self.target)
+
+
+class PaypalVerifyPaymentStatusActionButtonApplicant(ManageActionButton):
+    grok.order(3)
+    grok.context(INigeriaOnlinePayment)
+    grok.view(OPDPApplicant)
+    grok.require('waeup.manageApplication')
+    icon = 'actionicon_call.png'
+    text = _('Verify Paypal Payment Status')
+    target = 'verify_payment_status'
+
+    @property
+    def target_url(self):
+        if not module_activated(
+            self.context.__parent__.__parent__.year, self.context):
+            return ''
+        if self.context.p_state != 'paid' \
+            or self.context.r_company != u'paypal':
+            return ''
+        return self.view.url(self.view.context, self.target)
+
+
+# Webservice request views
+
+
+class PaypalRequestPaymentStatusPageApplicant(UtilityView, grok.View):
+    """ Capture order created before
+    """
+    grok.context(INigeriaApplicantOnlinePayment)
+    grok.name('capture_paypal_payment')
+    grok.require('waeup.payApplicant')
+
+
+    def update(self):
+        if not module_activated(
+            self.context.__parent__.__parent__.year, self.context):
+            self.flash(_('Forbidden'), type='danger')
+            self.redirect(self.url(self.context, '@@index'))
+            return
+        if self.context.p_state in ('paid', 'waived', 'scholarship'):
+            self.flash(_('This ticket has already been paid.'), type='danger')
+            return
+        response = capture_order(self.context.r_pay_reference)
+        if response.get("status", None) == "COMPLETED":
+            self.context.p_state = u'paid'
+        if not response:
+            self.flash(_('Paypal Order ID not found.'), type='danger')
+            return
+
+    def render(self):
+        self.redirect(self.url(self.context, '@@index'))
+        return
+
+
+# Forwarding pages
+
+class PaypalPageApplicant(KofaPage):
+    """ Create Paypal Order (and send user to paypal)
+    """
+    grok.context(INigeriaApplicantOnlinePayment)
+    grok.name('goto_paypal')
+    grok.require('waeup.payApplicant')
+    label = _('Pay via Paypal')
+    submit_button = _('Pay now')
+
+    # Nigerian Naira would be "NGN" but is not supported with
+    # most paypal accounts
+    currency_code = 'USD'
+
+    @property
+    def action(self):
+        return self.context.r_payment_link
+
+    def create_paypal_order(self, currency_code):
+        if self.context.r_pay_reference or self.context.p_state == 'paid':
+            return _("Payment ticket can't be re-sent to Paypal.")
+        if self.context.r_company and self.context.r_company != 'paypal':
+            return _("Payment ticket has been used for another payment gateway.")
+        now = datetime.utcnow()
+        if self.context.creation_date.tzinfo is not None:
+            # That's bad. Please store timezone-naive datetimes only!
+            now = self.context.creation_date.tzinfo.localize(now)
+        time_delta = now - self.context.creation_date
+        if time_delta.days > 7:
+            return _("This payment ticket is too old. Please create a new ticket.")
+        # The success url...
+        self.responseurl = self.url(self.context, 'capture_paypal_payment')
+        amount = Amount(value=self.context.amount_auth, currency_code=self.currency_code)
+        purchase_units = [
+                PurchaseUnit(description=self.context.p_id, amount=amount)
+                ]
+        order_id, payment_link, request_id, data = create_order(
+                purchase_units,
+                self.responseurl,         # success/authorized url
+                self.url(self.context),   # cancel/failed url
+                notify_url="https://h9.waeup.org/ipn"
+                )
+        if not order_id:  # an error happened...
+            err_msg = " ".join([x["description"] for x in data["details"]])
+            return _("Paypal order failed: %s") % err_msg
+        self.context.r_payment_link = payment_link
+        self.context.r_pay_reference = order_id
+        self.context.r_company = u'paypal'
+        self.customer = self.context.__parent__
+        self.customer.writeLogMessage(self, "Paypal Order Created: %s" % (
+            order_id))
+        return
+
+    def update(self):
+        if not module_activated(
+            self.context.__parent__.__parent__.year, self.context):
+            self.flash(_('Forbidden'), type='danger')
+            self.redirect(self.url(self.context, '@@index'))
+            return
+        self.paypal_error = u''
+        if not self.context.r_payment_link:
+            self.paypal_error = self.create_paypal_order(self.currency_code)
+        if self.paypal_error:
+            self.flash(error, type='danger')
+            self.redirect(self.url(self.context, '@@index'))
+            return
+        # Payment created - it must now be
+        # - authorized (by user)
+        # - captured (by us, afterwards)
+        if not self.context.r_company:
+            self.context.r_company = u'paypal'
+        return
+
+    def render(self):
+        if self.context.r_payment_link and self.context.p_state != 'paid':
+            self.redirect(self.context.r_payment_link, trusted=True)
+            return
+        self.redirect(self.url(self.context, '@@index'))
+
Index: /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/paypal/config.py
===================================================================
--- /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/paypal/config.py	(revision 17242)
+++ /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/paypal/config.py	(revision 17242)
@@ -0,0 +1,46 @@
+## $Id$
+##
+## Copyright (C) 2022 Uli Fouquet & Henrik Bettermann
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published by
+## the Free Software Foundation; either version 2 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 General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with this program; if not, write to the Free Software
+## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+##
+import ConfigParser
+import grok
+from zope.component import queryUtility
+from zope.interface import implementer
+from kofacustom.nigeria.paypal.interfaces import IPaypalConfig
+
+VERIFY_URL_PROD = 'https://ipnpb.paypal.com/cgi-bin/webscr'
+VERIFY_URL_TEST = 'https://ipnpb.sandbox.paypal.com/cgi-bin/webscr'
+VALID_PAYPAL_EMAILS = [
+        "sample_biz@paypal.com",
+        ]
+
+
+def parse_paypal_config(path):
+    """Get paypal credentials from config file
+
+    This is called on initialisation when the `kofacustomng:paypalconf`
+    directive is set in ZCML configuration.
+
+    Retrieve the respective config with: `getUtility(IPaypalConfig)`
+    """
+    parser = ConfigParser.SafeConfigParser({'id': None, 'secret': None, 'mode': 'sandbox',})
+    with open(path) as fp:
+        parser.readfp(fp)
+    return {
+            'client_id': parser.get('rest-client', 'id', None),
+            'client_secret': parser.get('rest-client', 'secret', None),
+            'mode': parser.get('rest-client', 'mode', 'sandbox'),
+            }
Index: /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/paypal/interfaces.py
===================================================================
--- /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/paypal/interfaces.py	(revision 17242)
+++ /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/paypal/interfaces.py	(revision 17242)
@@ -0,0 +1,27 @@
+## $Id$
+##
+## Copyright (C) 2022 Uli Fouquet & Henrik Bettermann
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published by
+## the Free Software Foundation; either version 2 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 General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with this program; if not, write to the Free Software
+## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+##
+from zope.configuration.fields import Path
+from zope.interface import Interface
+
+
+class IPaypalConfig(Interface):
+    path = Path(
+        title = u'Path',
+        description = u"Path to config file for Paypal REST API",
+        required = True,
+        )
Index: /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/paypal/ipn_listener.py
===================================================================
--- /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/paypal/ipn_listener.py	(revision 17242)
+++ /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/paypal/ipn_listener.py	(revision 17242)
@@ -0,0 +1,66 @@
+## $Id$
+##
+## Copyright (C) 2022 Uli Fouquet & Henrik Bettermann
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published by
+## the Free Software Foundation; either version 2 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 General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with this program; if not, write to the Free Software
+## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+##
+import grok
+import requests
+import urlparse
+from waeup.kofa.interfaces import IUniversity
+from kofacustom.nigeria.paypal.config import (
+        VERIFY_URL_PROD, VERIFY_URL_TEST, VALID_PAYPAL_EMAILS
+        )
+
+
+class IPNListener(grok.View):
+    grok.context(IUniversity)
+    grok.name('ipn')
+    grok.require('waeup.Public')
+
+    def render(self):
+        # we need the raw HTTP body to keep order of form vars
+        # vars in `form` are mangled by Zope and not in original order anymore
+        cache_stream = self.request.bodyStream.cacheStream
+        cache_stream.seek(0)
+        content = cache_stream.read()
+        verified = verify_notification(content)
+        return u''
+
+
+def verify_notification(params_str, verify_url=VERIFY_URL_TEST):
+    """Contact paypal to verify a received notification.
+
+    In case of payments happening, paypal will contact us by calling our IPN
+    listener with a POST request.
+
+    This request will provide POST vars that have to be returned to Paypal
+    slightly modified (and within 30 seconds).
+
+    See https://developer.paypal.com/api/nvp-soap/ipn/IPNIntro/ for details.
+
+    """
+    params = [('cmd', '_notify-validate')]
+    params.extend(urlparse.parse_qsl(params_str, keep_blank_values=True))
+    headers =  {'content-type': 'application/x-www-form-urlencoded',
+                'user-agent': 'Kofa IPN listener'}
+    req = requests.post(verify_url, params=params, headers=headers, verify=True)
+    req.raise_for_status()
+    params_dict = dict(params)
+    if params_dict.get("receiver_email", None) not in VALID_PAYPAL_EMAILS:
+        # we got a notfication for someone else
+        return False
+    if req.text == 'VERIFIED':  # "VERIFIED", "INVALID" (or something else)
+        return True
+    return False
Index: /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/paypal/rest.py
===================================================================
--- /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/paypal/rest.py	(revision 17242)
+++ /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/paypal/rest.py	(revision 17242)
@@ -0,0 +1,116 @@
+## $Id$
+##
+## Copyright (C) 2022 Uli Fouquet & Henrik Bettermann
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published by
+## the Free Software Foundation; either version 2 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 General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with this program; if not, write to the Free Software
+## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+##
+import json
+import requests
+import uuid
+from zope.component import getUtility, queryUtility
+from kofacustom.nigeria.paypal.interfaces import IPaypalConfig
+
+
+PAYPAL_REST_URLS = {
+        'sandbox': 'https://api-m.sandbox.paypal.com',
+        'live':    'https://api-m.paypal.com'}
+
+
+def get_auth_token():
+    """Get an authentication token from paypal
+
+    Requests to paypal must provide an authorization token, which can be
+    retrieved by sending REST credentials (`client_id` and `client_secret`) as
+    provided in your Paypal developer account.
+
+    During livespan of tokens, this call will not create new ones.
+    """
+    config = getUtility(IPaypalConfig)
+    url = "%s/v1/oauth2/token" % PAYPAL_REST_URLS[config['mode']]
+    auth = requests.auth.HTTPBasicAuth(
+            config['client_id'], config['client_secret'])
+    headers = {"content-type": "application/x-www-form-urlencoded"}
+    params = [("grant_type", "client_credentials"), ]
+    response = requests.post(url, auth=auth, headers=headers, params=params)
+    response.raise_for_status()
+    result = response.json()
+    return result["access_token"]
+
+
+def create_order(
+        purchase_units, return_url, cancel_url,
+        request_id=None, notify_url=None):
+    token = get_auth_token()
+    if request_id is None:
+        request_id = str(uuid.uuid4())
+    headers = {
+        "content-type": "application/json",
+        "authorization": "Bearer %s" % token,
+        "paypal-request-id": request_id
+    }
+
+    json_data = {
+            "intent": "CAPTURE",
+            "payment_source": {
+                "paypal": {
+                    "experience_context": {
+                        "landing_page": "NO_PREFERENCE",
+                        "user_action": "CONTINUE",
+                        "brand_name": "WAeUP",
+                        "shipping_preference": "NO_SHIPPING",
+                        "cancel_url": cancel_url,
+                        "return_url": return_url}
+                    }
+                },
+            "purchase_units": []
+        }
+    units = [x.json() for x in purchase_units]
+    for n, unit in enumerate(units):
+        unit["reference_id"] = n
+        json_data["purchase_units"].append(unit)
+    if notify_url:
+        json_data["payment_source"]["paypal"][
+                "experience_context"]["notify_url"] = notify_url
+    config = getUtility(IPaypalConfig)
+    url = "%s/v2/checkout/orders" % PAYPAL_REST_URLS[config['mode']]
+    response = requests.post(url,
+            headers=headers, json=json_data)
+    try:
+        response.raise_for_status()
+    except requests.HTTPError as err:
+        data = err.response.json()
+        return None, None, request_id, data
+    data = response.json()
+    order_id = data['id']  # order token
+    payment_link = [x for x in data['links']
+            if x["rel"] == "payer-action"][0]["href"]
+    return order_id, payment_link, request_id, data
+
+
+def capture_order(order_id, request_id=None):
+    config = getUtility(IPaypalConfig)
+    token = get_auth_token()
+    headers = {
+        "content-type": "application/json",
+        "authorization": "Bearer %s" % token,
+        "paypal-request-id": request_id
+    }
+    if request_id is not None:
+        headers["paypal-request-id"] = request_id
+    url = "%s/v2/checkout/orders/%s/capture" % (
+        PAYPAL_REST_URLS[config['mode']], order_id)
+    response = requests.post(url, headers=headers)
+    response.raise_for_status()
+    data = response.json()
+    return data
Index: /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/paypal/tests.py
===================================================================
--- /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/paypal/tests.py	(revision 17242)
+++ /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/paypal/tests.py	(revision 17242)
@@ -0,0 +1,325 @@
+## $Id$
+##
+## Copyright (C) 2022 Uli Fouquet & Henrik Bettermann
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published by
+## the Free Software Foundation; either version 2 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 General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with this program; if not, write to the Free Software
+## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+##
+import datetime
+import mock
+import os
+import unittest
+from zope.component import queryUtility, createObject
+from waeup.kofa.applicants.tests.test_browser import ApplicantsFullSetup
+from waeup.kofa.browser.tests.test_browser import UniversitySetup
+from waeup.kofa.configuration import SessionConfiguration
+from kofacustom.nigeria.paypal.config import VALID_PAYPAL_EMAILS
+from kofacustom.nigeria.paypal.interfaces import IPaypalConfig
+from kofacustom.nigeria.paypal.ipn_listener import VERIFY_URL_TEST, verify_notification
+from kofacustom.nigeria.paypal import Amount, PurchaseUnit, module_activated
+from kofacustom.nigeria.paypal.rest import (
+        get_auth_token, create_order, capture_order)
+from kofacustom.nigeria.testing import FunctionalLayer
+
+
+RUN_EXTERNAL_TESTS = False
+
+
+class MockResponse(object):
+    def __init__(self, *args, **kw):
+        self.text = "VERIFIED"
+        self.json_data = None
+
+    def raise_for_status(self):
+        pass
+
+    def json(self):
+        return self.json_data
+
+
+class PaypalIPNListenerTests(unittest.TestCase):
+    def setUp(self):
+        pass
+
+    def tearDown(self):
+        pass
+
+    @mock.patch("requests.post", side_effect=MockResponse)
+    def test_verify_transaction(self, post):
+        self.assertEqual(verify_notification("foo=bar&bar=baz"), False)
+        self.assertTrue(post.called)
+        valid_email = VALID_PAYPAL_EMAILS[0]
+        self.assertEqual(
+            verify_notification("foo=bar&receiver_email=%s" % valid_email), True
+        )
+
+
+class PaypalConfigTests(unittest.TestCase):
+
+    layer = FunctionalLayer
+
+    def test_paypal_config_util_available(self):
+        config = queryUtility(IPaypalConfig)
+        self.assertTrue(config is not None)
+
+
+def mocked_requests_post(*args, **kw):
+
+    response = MockResponse()
+    response.status_code = 401
+    if args[0].endswith("v1/oauth2/token"):
+        response.status_code = 200
+        response.json_data = {
+            "scope": "https://uri.paypal.com/services/invoicing https://uri.paypal.com/services/vault/payment-tokens/read https://uri.paypal.com/services/disputes/read-buyer https://uri.paypal.com/services/payments/realtimepayment https://uri.paypal.com/services/disputes/update-seller https://uri.paypal.com/services/payments/payment/authcapture openid https://uri.paypal.com/services/disputes/read-seller Braintree:Vault https://uri.paypal.com/services/payments/refund https://api.paypal.com/v1/vault/credit-card https://uri.paypal.com/services/billing-agreements https://api.paypal.com/v1/payments/.* https://uri.paypal.com/payments/payouts https://uri.paypal.com/services/vault/payment-tokens/readwrite https://api.paypal.com/v1/vault/credit-card/.* https://uri.paypal.com/services/shipping/trackers/readwrite https://uri.paypal.com/services/subscriptions https://uri.paypal.com/services/applications/webhooks",
+            "access_token": "A21AAL21A1ohAg6u75tLLi3LSshFmONDD8JC_AOd0Bkmc2cloeeSZIofamBbFXEYHvq7C5ViKJeSDq9PMfoV6shmfRhVyvDIg",
+            "token_type": "Bearer",
+            "app_id": "APP-80W284485P519543T",
+            "expires_in": 32400,
+            "nonce": "2022-12-14T08:05:21ZF5NvAXAkc46HOplYDYqHnBBPJDZJbwd4e3zDgaI5Vo0",
+        }
+    if args[0].endswith("/v2/checkout/orders"):
+        response.status_code = 200
+        response.json_data = {
+            "id": "2LP804900J8837429",
+            "status": "PAYER_ACTION_REQUIRED",
+            "payment_source": {"paypal": {}},
+            "links": [
+                {
+                    "href": "https://api.sandbox.paypal.com/v2/checkout/orders/2LP804900J8837429",
+                    "rel": "self",
+                    "method": "GET",
+                },
+                {
+                    "href": "https://www.sandbox.paypal.com/checkoutnow?token=2LP804900J8837429",
+                    "rel": "payer-action",
+                    "method": "GET",
+                },
+            ],
+        }
+    if args[0].endswith("/capture"):
+        response.status_code = 201
+        response.json_data = {
+                "id":"2LP804900J8837429",
+                "status":"COMPLETED",
+                "payment_source":{
+                    "paypal":{
+                        "email_address":"sb-90spn23140065@personal.example.com",
+                        "account_id":"3LP3QX8H9YC3G",
+                        "name":{
+                            "given_name": "John",
+                            "surname":"Doe"
+                            },
+                        "address":{"country_code":"DE"}}},
+                    "purchase_units":[
+                        {
+                            "reference_id":"0",
+                            "payments":{
+                                "captures":[
+                                    {
+                                        "id":"3JF41209TB864721F",
+                                        "status":"COMPLETED",
+                                        "amount":{
+                                            "currency_code":"USD",
+                                            "value":"3.23"},
+                                        "final_capture":True,
+                                        "seller_protection":{
+                                            "status":"ELIGIBLE",
+                                            "dispute_categories":[
+                                                "ITEM_NOT_RECEIVED",
+                                                "UNAUTHORIZED_TRANSACTION"]
+                                            },
+                                        "seller_receivable_breakdown":{
+                                            "gross_amount":{
+                                                "currency_code":"USD",
+                                                "value":"3.23"},
+                                            "paypal_fee":{
+                                                "currency_code":"USD",
+                                                "value":"0.36"},
+                                            "net_amount":{
+                                                "currency_code":"USD",
+                                                "value":"2.87"}
+                                            },
+                                        "custom_id":"my-custom-id",
+                                        "links":[
+                                            {
+                                                "href":"https://api.sandbox.paypal.com/v2/payments/captures/3JF41209TB864721F",
+                                                "rel":"self","method":"GET"
+                                                },
+                                            {
+                                                "href":"https://api.sandbox.paypal.com/v2/payments/captures/3JF41209TB864721F/refund",
+                                                "rel":"refund","method":"POST"
+                                                },
+                                            {
+                                                "href":"https://api.sandbox.paypal.com/v2/checkout/orders/2LP804900J8837429",
+                                                "rel":"up",
+                                                "method":"GET"
+                                                }
+                                            ],
+                                        "create_time":"2022-12-17T16:34:12Z",
+                                        "update_time":"2022-12-17T16:34:12Z"
+                                        }
+                                    ]
+                                }
+                            }
+                        ],
+                        "payer":{
+                                "name":{
+                                    "given_name":"John",
+                                    "surname":"Doe"
+                                    },
+                                "email_address":"sb-90spn23140065@personal.example.com",
+                                "payer_id":"3LP3QX8H9YC3G","address":{
+                                    "country_code":"DE"
+                                    }
+                                },
+                        "links":[
+                                {
+                                    "href":"https://api.sandbox.paypal.com/v2/checkout/orders/2LP804900J8837429",
+                                    "rel":"self",
+                                    "method":"GET"
+                                    }
+                                ]
+                        }
+    return response
+
+
+sample_units = [PurchaseUnit(description="Test Good", amount=Amount(value="3.23"))]
+
+
+class PaypalRESTClientTests(unittest.TestCase):
+
+    layer = FunctionalLayer   # we need the IPaypalConfig data
+
+    @unittest.skipUnless(RUN_EXTERNAL_TESTS, "Live paypal tests skipped")
+    def test_get_auth_token(self):
+        result = get_auth_token()
+        self.assertTrue(isinstance(result, basestring))
+
+    @unittest.skipUnless(RUN_EXTERNAL_TESTS, "Live paypal tests skipped")
+    def test_create_order_live(self):
+        token, url, request_id, data = create_order(
+            sample_units, "http://fake-return-url", "http://fake-cancel-url"
+        )
+        self.assertTrue(isinstance(token, basestring))
+        self.assertTrue("paypal.com/checkoutnow?token=" in url)
+        self.order_id = token
+
+    @mock.patch("requests.post", side_effect=mocked_requests_post)
+    def test_create_order(self, mock_post):
+        token, url, request_id, data = create_order(
+            sample_units, "http://fake-return-url", "http://fake-cancel-url"
+        )
+        self.assertEqual(len(mock_post.call_args_list), 2)
+        self.assertEqual(token, "2LP804900J8837429")
+
+    @mock.patch("requests.post", side_effect=mocked_requests_post)
+    def test_capture_order(self, mock_post):
+        result = capture_order("2LP804900J8837429")
+        self.assertEqual(len(mock_post.call_args_list), 2)
+        self.assertEqual(result["status"], "COMPLETED")
+
+
+class PaypalBaseObjectsTests(unittest.TestCase):
+    def test_amount(self):
+        # we can create Amount objects
+        amt = Amount()
+        self.assertEqual(amt.value, "0.00")
+        self.assertEqual(amt.currency_code, "USD")
+
+    def test_amount_json(self):
+        # we can get a JSON representation of amounts
+        amt = Amount()
+        self.assertEqual(amt.json(), {"value": "0.00", "currency_code": "USD"})
+
+    def test_purchase_unit(self):
+        # we can create PurchaseItem instances
+        item = PurchaseUnit()
+        self.assertTrue(isinstance(item.amount, Amount))
+        self.assertEqual(item.custom_id, None)
+        self.assertEqual(item.description, "")
+
+    def test_purchase_unit_json(self):
+        # we can get PurchaseItem instances as JSON
+        item = PurchaseUnit()
+        self.assertEqual(
+            item.json(),
+            {
+                "amount": {"value": "0.00", "currency_code": "USD"},
+                "description": "",
+            },
+        )
+
+
+class PaypalHelperTests(UniversitySetup):
+
+    layer = FunctionalLayer   # we need the IPaypalConfig data
+
+    def test_module_activated(self):
+        session_conf1 = SessionConfiguration()
+        session_conf2 = SessionConfiguration()
+        curr_year = datetime.datetime.now().year
+        session_conf1.academic_session = curr_year - 2
+        session_conf2.academic_session = curr_year
+        session_conf1.paypal_enabled = True
+        session_conf2.paypal_enabled = True
+        # session_conf2 yet not active...
+        self.app['configuration'].addSessionConfiguration(session_conf1)
+        self.assertFalse(module_activated(2000, None))
+        self.assertFalse(module_activated(curr_year, None))
+        self.assertTrue(module_activated(curr_year - 2, None))
+        payment = createObject('waeup.StudentOnlinePayment')
+        payment.r_company = 'unknown'
+        self.assertFalse(module_activated(curr_year - 2, payment))
+        payment.r_company = 'paypal'
+        self.assertTrue(module_activated(curr_year - 2, payment))
+        self.assertFalse(module_activated(curr_year, payment))
+        # activate also session_conf2
+        self.app['configuration'].addSessionConfiguration(session_conf2)
+        self.assertTrue(module_activated(curr_year - 1, payment))
+        self.assertTrue(module_activated(curr_year, payment))
+
+
+class PaypalTestsApplicants(ApplicantsFullSetup):
+    """Tests for the Paypal payment gateway.
+    """
+
+    layer = FunctionalLayer
+
+    def setUp(self):
+        super(PaypalTestsApplicants, self).setUp()
+        configuration = SessionConfiguration()
+        configuration.academic_session = datetime.datetime.now().year - 2
+        configuration.paypal_enabled = True
+        self.app['configuration'].addSessionConfiguration(configuration)
+        self.browser.addHeader('Authorization', 'Basic mgr:mgrpw')
+        self.browser.open(self.manage_path)
+        #IWorkflowState(self.student).setState('started')
+        super(PaypalTestsApplicants, self).fill_correct_values()
+        self.applicantscontainer.application_fee = 10.0
+        self.browser.getControl(name="form.nationality").value = ['NG']
+        self.browser.getControl(name="transition").value = ['start']
+        self.browser.getControl("Save").click()
+        self.browser.getControl("Add online").click()
+        self.assertMatches('...ticket created...',
+                           self.browser.contents)
+        self.payment = self.applicant.values()[0]
+        self.payment_url = self.browser.url
+
+    #@unittest.skipUnless(RUN_EXTERNAL_TESTS, "Live paypal tests skipped")
+    #def test_applicant_paypal_form(self):
+    #    import pdb; pdb.set_trace()
+    #    self.browser.getLink("Pay via Paypal").click()
+    #    logfile = os.path.join(
+    #        self.app['datacenter'].storage, 'logs', 'applicants.log')
+    #    logcontent = open(logfile).read()
+    #    self.assertTrue(self.payment.r_pay_reference in logcontent)
Index: /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/students/tests/test_export.py
===================================================================
--- /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/students/tests/test_export.py	(revision 17241)
+++ /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/students/tests/test_export.py	(revision 17242)
@@ -126,9 +126,9 @@
             'p_id,p_item,p_level,p_option,p_session,p_split_data,p_state,payment_date,provider_amt,'
             'r_amount_approved,r_card_num,r_code,r_company,r_desc,'
-            'r_pay_reference,thirdparty_amt,student_id,state,'
+            'r_pay_reference,r_payment_link,thirdparty_amt,student_id,state,'
             'current_session\r\n'
             '666,12.12,2012-04-01 13:12:01#,,,schoolfee,[],1,my-id,p-item,'
             '100,,%s,,paid,2012-04-01 14:12:01#,,12.12,'
-            '789,r-code,interswitch,,,,A111111,created,2012\r\n' % (curr_year-6),
+            '789,r-code,interswitch,,,,,A111111,created,2012\r\n' % (curr_year-6),
             result
             )
@@ -191,10 +191,10 @@
             'p_category,p_combi,p_current,p_id,p_item,p_level,p_option,p_session,p_split_data,p_state,'
             'payment_date,provider_amt,r_amount_approved,r_card_num,r_code,'
-            'r_company,r_desc,r_pay_reference,thirdparty_amt,student_id,'
+            'r_company,r_desc,r_pay_reference,r_payment_link,thirdparty_amt,student_id,'
             'matric_number,reg_number,firstname,middlename,lastname,sex,state,'
             'current_session,entry_session,entry_mode,faccode,depcode,certcode,lga\r\n'
 
             '666,12.12,%s-04-01 13:12:01#,,,,schoolfee,[],1,my-id,p-item,'
-            '100,,%s,,paid,%s-04-01 14:12:01#,,12.12,,r-code,,,,,A111111,'
+            '100,,%s,,paid,%s-04-01 14:12:01#,,12.12,,r-code,,,,,,A111111,'
             '234,123,Anna,M.,Tester,f,created,2012,2010,ug_ft,NA,NA,CERT1,\r\n'
             % (curr_year-6, curr_year-6, curr_year-6)
Index: /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/zcml.py
===================================================================
--- /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/zcml.py	(revision 17242)
+++ /main/kofacustom.nigeria/trunk/src/kofacustom/nigeria/zcml.py	(revision 17242)
@@ -0,0 +1,60 @@
+## $Id$
+##
+## Copyright (C) 2011 Uli Fouquet & Henrik Bettermann
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published by
+## the Free Software Foundation; either version 2 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 General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with this program; if not, write to the Free Software
+## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+##
+import os
+from zope.component.zcml import handler
+from zope.configuration.exceptions import ConfigurationError
+from kofacustom.nigeria.paypal.interfaces import IPaypalConfig
+from kofacustom.nigeria.paypal.config import parse_paypal_config
+
+
+def paypal_handler(path):
+    """ZCML handler that registers paypal configuration.
+
+    We expect paypal credentials written down in a config file. The
+    path to this config file can be set with the ZCML `paypalconf`
+    directive, which is handled here and looks like this::
+
+      <kofacustomng:paypalconf path='/some/path/to/paypal.conf' />
+
+    This handler requires the given path to exist and to be a file.
+
+    If the file exists and is readable, a dict with ``path`` and other
+    values read from the configuration file is registered as a global
+    unnamed utility for the `IPaypalConfig` interface.
+    """
+    if not os.path.exists(path):
+        raise ConfigurationError("No such file: %s" % path)
+    if not os.path.isfile(path):
+        raise ConfigurationError("Not a regular file: %s" % path)
+    config_dict = parse_paypal_config(path)
+    config_dict["path"] = path
+    return handler(
+        'registerUtility', config_dict, IPaypalConfig, '')
+
+
+def paypal_conf(context, path):
+    """Handler for ZCML paypalconf directive.
+
+    This handler registers the `paypal_handler` above to perform the
+    real configuration action.
+    """
+    context.action(
+        discriminator=('utility', IPaypalConfig, ''),
+        callable=paypal_handler,
+        args=(path, )
+        )
Index: /main/kofacustom.nigeria/trunk/versions.cfg
===================================================================
--- /main/kofacustom.nigeria/trunk/versions.cfg	(revision 17241)
+++ /main/kofacustom.nigeria/trunk/versions.cfg	(revision 17242)
@@ -5,2 +5,9 @@
 
 [versions]
+# last py2-compatible version
+certifi = 2021.10.08
+chardet = 4.0.0
+idna = 2.10
+# last py2-compatible version
+requests = 2.27.1
+urllib3 = 1.26.13
