Index: /main/waeup.kofa/trunk/CHANGES.txt
===================================================================
--- /main/waeup.kofa/trunk/CHANGES.txt	(revision 13871)
+++ /main/waeup.kofa/trunk/CHANGES.txt	(revision 13872)
@@ -4,4 +4,6 @@
 1.4.2.dev0 (unreleased)
 =======================
+
+* Add  `ApplicantOnlinePaymentProcessor`.
 
 * Improve `AddStudyLevelFormPage`.
Index: /main/waeup.kofa/trunk/src/waeup/kofa/applicants/batching.py
===================================================================
--- /main/waeup.kofa/trunk/src/waeup/kofa/applicants/batching.py	(revision 13871)
+++ /main/waeup.kofa/trunk/src/waeup/kofa/applicants/batching.py	(revision 13872)
@@ -22,14 +22,16 @@
 from zope.schema import getFields
 from zope.interface import Interface
-from zope.component import queryUtility
+from zope.component import queryUtility, getUtility
 from hurry.workflow.interfaces import IWorkflowState
 from zope.catalog.interfaces import ICatalog
 from waeup.kofa.interfaces import (
     IBatchProcessor, IObjectConverter, FatalCSVError, IGNORE_MARKER,
-    IObjectHistory, IUserAccount)
+    IObjectHistory, IUserAccount, DuplicationError)
 from waeup.kofa.interfaces import MessageFactory as _
+from waeup.kofa.payments.interfaces import IPayer
 from waeup.kofa.utils.batching import BatchProcessor
 from waeup.kofa.applicants.interfaces import (
-    IApplicantsContainer, IApplicant, IApplicantUpdateByRegNo)
+    IApplicantsContainer, IApplicant, IApplicantUpdateByRegNo,
+    IApplicantOnlinePayment)
 from waeup.kofa.applicants.workflow import  IMPORTABLE_STATES, CREATED
 
@@ -335,2 +337,133 @@
             return 'Applicant is blocked.'
         return None
+
+class ApplicantOnlinePaymentProcessor(BatchProcessor):
+    """The Applicant Online Payment Processor imports applicant payment tickets.
+    The tickets are located in the applicant container.
+    The only additional locator is `p_id`, the object id.
+
+    The `checkConversion` method checks the format of the payment identifier.
+    In create mode it does also ensures that same p_id does not exist
+    elsewhere. It must be portal-wide unique.
+
+    When adding a payment ticket, the `addEntry` method checks if a
+    payment has already been made. If so, a `DuplicationError` is raised.
+    """
+    grok.implements(IBatchProcessor)
+    grok.provides(IBatchProcessor)
+    grok.context(Interface)
+    util_name = 'applicantpaymentprocessor'
+    grok.name(util_name)
+
+    name = _('ApplicantOnlinePayment Processor')
+    iface = IApplicantOnlinePayment
+    factory_name = 'waeup.ApplicantOnlinePayment'
+
+    location_fields = ['applicant_id', 'p_id']
+
+    @property
+    def available_fields(self):
+        af = sorted(list(set(
+            self.location_fields + getFields(self.iface).keys())) + ['p_id',])
+        af.remove('display_item')
+        return af
+
+    def parentsExist(self, row, site):
+        return self.getParent(row, site) is not None
+
+    def getParent(self, row, site):
+        applicant_id = row['applicant_id']
+        cat = queryUtility(ICatalog, name='applicants_catalog')
+        results = list(
+            cat.searchResults(applicant_id=(applicant_id, applicant_id)))
+        if results:
+            return results[0]
+        return None
+
+    def getEntry(self, row, site):
+        applicant = self.getParent(row, site)
+        if applicant is None:
+            return None
+        p_id = row.get('p_id', None)
+        if p_id in (None, IGNORE_MARKER):
+            return None
+        # We can use the hash symbol at the end of p_id in import files
+        # to avoid annoying automatic number transformation
+        # by Excel or Calc
+        p_id = p_id.strip('#')
+        entry = applicant.get(p_id)
+        return entry
+
+    def entryExists(self, row, site):
+        return self.getEntry(row, site) is not None
+
+    def updateEntry(self, obj, row, site, filename):
+        """Update obj to the values given in row.
+        """
+        items_changed = super(ApplicantOnlinePaymentProcessor, self).updateEntry(
+            obj, row, site, filename)
+        applicant = self.getParent(row, site)
+        applicant.__parent__.__parent__.logger.info(
+            '%s - %s - %s - updated: %s'
+            % (self.name, filename, applicant.applicant_id, items_changed))
+        return
+
+    def samePaymentMade(self, applicant, category):
+        for key in applicant.keys():
+            ticket = applicant[key]
+            if ticket.p_state == 'paid' and\
+                ticket.p_category == category:
+                  return True
+        return False
+
+    def addEntry(self, obj, row, site):
+        applicant = self.getParent(row, site)
+        p_id = row['p_id'].strip('#')
+        if self.samePaymentMade(applicant, obj.p_category):
+            applicant.__parent__.__parent__.logger.info(
+                '%s - %s - previous update cancelled'
+                % (self.name, applicant.applicant_id))
+            raise DuplicationError('Payment has already been made.')
+        applicant[p_id] = obj
+        return
+
+    def delEntry(self, row, site):
+        payment = self.getEntry(row, site)
+        applicant = self.getParent(row, site)
+        if payment is not None:
+            applicant.__parent__.__parent__.logger.info('%s - Payment ticket removed: %s'
+                % (applicant.applicant_id, payment.p_id))
+            del applicant[payment.p_id]
+        return
+
+    def checkConversion(self, row, mode='ignore'):
+        """Validates all values in row.
+        """
+        errs, inv_errs, conv_dict = super(
+            ApplicantOnlinePaymentProcessor, self).checkConversion(row, mode=mode)
+        # We have to check p_id.
+        p_id = row.get('p_id', None)
+        if mode == 'create' and p_id in (None, IGNORE_MARKER):
+            timestamp = ("%d" % int(time()*10000))[1:]
+            p_id = "p%s" % timestamp
+            conv_dict['p_id'] = p_id
+            return errs, inv_errs, conv_dict
+        elif p_id in (None, IGNORE_MARKER):
+            errs.append(('p_id','missing'))
+            return errs, inv_errs, conv_dict
+        else:
+            p_id = p_id.strip('#')
+            if not len(p_id) == 14:
+                errs.append(('p_id','invalid length'))
+                return errs, inv_errs, conv_dict
+        if mode == 'create':
+            cat = getUtility(ICatalog, name='payments_catalog')
+            results = list(cat.searchResults(p_id=(p_id, p_id)))
+            if len(results) > 0:
+                sids = [IPayer(payment).id for payment in results]
+                sids_string = ''
+                for id in sids:
+                    sids_string += '%s ' % id
+                errs.append(('p_id','p_id exists in %s' % sids_string))
+                return errs, inv_errs, conv_dict
+        return errs, inv_errs, conv_dict
Index: /main/waeup.kofa/trunk/src/waeup/kofa/applicants/tests/sample_payment_data.csv
===================================================================
--- /main/waeup.kofa/trunk/src/waeup/kofa/applicants/tests/sample_payment_data.csv	(revision 13872)
+++ /main/waeup.kofa/trunk/src/waeup/kofa/applicants/tests/sample_payment_data.csv	(revision 13872)
@@ -0,0 +1,2 @@
+applicant_id,p_state,p_category,p_id,p_session,r_amount_approved,amount_auth,creation_date,type
+dp2011_1234,paid,application,p1266236341955,2015,19500.1,19500.1,2010-11-25 20:16:33.757 WAT,online
Index: /main/waeup.kofa/trunk/src/waeup/kofa/applicants/tests/test_batching.py
===================================================================
--- /main/waeup.kofa/trunk/src/waeup/kofa/applicants/tests/test_batching.py	(revision 13871)
+++ /main/waeup.kofa/trunk/src/waeup/kofa/applicants/tests/test_batching.py	(revision 13872)
@@ -33,5 +33,6 @@
 from waeup.kofa.app import University
 from waeup.kofa.applicants.batching import (
-    ApplicantsContainerProcessor, ApplicantProcessor)
+    ApplicantsContainerProcessor, ApplicantProcessor,
+    ApplicantOnlinePaymentProcessor)
 from waeup.kofa.applicants.container import ApplicantsContainer
 from waeup.kofa.applicants.applicant import Applicant
@@ -39,5 +40,6 @@
 from waeup.kofa.university.department import Department
 from waeup.kofa.testing import FunctionalLayer, FunctionalTestCase
-from waeup.kofa.interfaces import IBatchProcessor, IUserAccount
+from waeup.kofa.interfaces import (
+    IBatchProcessor, IUserAccount, DuplicationError)
 from waeup.kofa.applicants.workflow import CREATED
 
@@ -75,4 +77,11 @@
 
 APPLICANT_HEADER_FIELDS_UPDATE2 = APPLICANT_SAMPLE_DATA_UPDATE2.split(
+    '\n')[0].split(',')
+
+PAYMENT_SAMPLE_DATA = open(
+    os.path.join(os.path.dirname(__file__), 'sample_payment_data.csv'),
+    'rb').read()
+
+PAYMENT_HEADER_FIELDS = PAYMENT_SAMPLE_DATA.split(
     '\n')[0].split(',')
 
@@ -225,4 +234,7 @@
             self.application_number]
         self.workdir = tempfile.mkdtemp()
+
+        self.logfile = os.path.join(
+            self.app['datacenter'].storage, 'logs', 'applicants.log')
         return
 
@@ -252,7 +264,4 @@
         open(self.csv_file_update, 'wb').write(APPLICANT_SAMPLE_DATA_UPDATE)
         open(self.csv_file_update2, 'wb').write(APPLICANT_SAMPLE_DATA_UPDATE2)
-
-        self.logfile = os.path.join(
-            self.app['datacenter'].storage, 'logs', 'applicants.log')
 
     def test_interface(self):
@@ -415,2 +424,111 @@
             logcontent)
         shutil.rmtree(os.path.dirname(fin_file))
+
+class PaymentProcessorTest(ApplicantImportExportSetup):
+
+    def setUp(self):
+        super(PaymentProcessorTest, self).setUp()
+
+        applicant = Applicant()
+        applicant.firstname = u'Anna2'
+        applicant.lastname = u'Tester'
+        applicant.applicant_id = u'dp2011_1234'
+        self.app['applicants']['dp2011'].addApplicant(applicant)
+        payment = createObject(u'waeup.ApplicantOnlinePayment')
+        payment.p_id = 'p120'
+        payment.p_session = 2012
+        payment.p_category = 'application'
+        payment.p_state = 'paid'
+        applicant['p120'] = payment
+        self.applicant2 = applicant
+        self.processor = ApplicantOnlinePaymentProcessor()
+        self.csv_file = os.path.join(
+            self.workdir, 'sample_payment_data.csv')
+        open(self.csv_file, 'wb').write(PAYMENT_SAMPLE_DATA)
+
+    def test_interface(self):
+        # Make sure we fulfill the interface contracts.
+        assert verifyObject(IBatchProcessor, self.processor) is True
+        assert verifyClass(
+            IBatchProcessor, ApplicantOnlinePaymentProcessor) is True
+
+    def test_getEntry(self):
+        assert self.processor.getEntry(
+            dict(applicant_id='ID_NONE', p_id='nonsense'), self.app) is None
+        assert self.processor.getEntry(
+            dict(applicant_id=self.applicant2.applicant_id, p_id='p120'),
+            self.app) is self.applicant2['p120']
+        assert self.processor.getEntry(
+            dict(applicant_id=self.applicant2.applicant_id, p_id='p120#'),
+            self.app) is self.applicant2['p120']
+
+    def test_delEntry(self):
+        assert self.processor.getEntry(
+            dict(applicant_id=self.applicant2.applicant_id, p_id='p120'),
+            self.app) is self.applicant2['p120']
+        self.assertEqual(len(self.applicant2.keys()),1)
+        self.processor.delEntry(
+            dict(applicant_id=self.applicant2.applicant_id, p_id='p120'),
+            self.app)
+        assert self.processor.getEntry(
+            dict(applicant_id=self.applicant2.applicant_id, p_id='p120'),
+            self.app) is None
+        self.assertEqual(len(self.applicant.keys()),0)
+
+    def test_addEntry(self):
+        self.assertEqual(len(self.applicant2.keys()),1)
+        self.processor.delEntry(
+            dict(applicant_id=self.applicant2.applicant_id, p_id='p120'),
+            self.app)
+        payment1 = createObject(u'waeup.ApplicantOnlinePayment')
+        payment1.p_category = 'application'
+        payment1.p_id = 'p234'
+        self.processor.addEntry(
+            payment1, dict(applicant_id=self.applicant2.applicant_id, p_id='p234'),
+            self.app)
+        self.assertEqual(len(self.applicant2.keys()),1)
+        self.assertEqual(self.applicant2['p234'].p_id, 'p234')
+        # Same payment must not exist.
+        payment1.p_state = 'paid'
+        payment2 = createObject(u'waeup.ApplicantOnlinePayment')
+        payment2.p_id = 'p456'
+        payment2.p_category = 'application'
+        self.assertRaises(
+            DuplicationError, self.processor.addEntry, payment2,
+            dict(applicant_id=self.applicant2.applicant_id, p_id='p456'), self.app)
+
+    def test_import(self):
+        num, num_warns, fin_file, fail_file = self.processor.doImport(
+            self.csv_file, PAYMENT_HEADER_FIELDS,'create')
+        self.assertEqual(num_warns,1)
+        fail_file = open(fail_file).read()
+        self.assertTrue('Payment has already been made' in fail_file)
+        self.processor.delEntry(
+            dict(applicant_id=self.applicant2.applicant_id, p_id='p120'),
+            self.app)
+        num, num_warns, fin_file, fail_file = self.processor.doImport(
+            self.csv_file, PAYMENT_HEADER_FIELDS,'create')
+        self.assertEqual(num_warns,0)
+        payment = self.processor.getEntry(dict(applicant_id='dp2011_1234',
+            p_id='p1266236341955'), self.app)
+        self.assertEqual(payment.p_id, 'p1266236341955')
+        cdate = payment.creation_date.strftime("%Y-%m-%d %H:%M:%S")
+        # Ooooh, still the old problem, see
+        # http://mail.dzug.org/mailman/archives/zope/2006-August/001153.html.
+        # WAT is interpreted as GMT-1 and not GMT+1
+        self.assertEqual(cdate, '2010-11-25 21:16:33')
+        self.assertEqual(str(payment.creation_date.tzinfo),'UTC')
+        shutil.rmtree(os.path.dirname(fin_file))
+        logcontent = open(self.logfile).read()
+        # Logging message from updateEntry
+        self.assertTrue(
+            'INFO - system - ApplicantOnlinePayment Processor - dp2011_1234 - '
+            'previous update cancelled'
+            in logcontent)
+        self.assertTrue(
+            'INFO - system - ApplicantOnlinePayment Processor - '
+            'sample_payment_data - dp2011_1234 - updated: p_id=p1266236341955, '
+            'creation_date=2010-11-25 21:16:33.757000+00:00, '
+            'r_amount_approved=19500.1, p_category=application, '
+            'amount_auth=19500.1, p_session=2015, p_state=paid'
+            in logcontent)
Index: /main/waeup.kofa/trunk/src/waeup/kofa/doctests/batchprocessing_browser.txt
===================================================================
--- /main/waeup.kofa/trunk/src/waeup/kofa/doctests/batchprocessing_browser.txt	(revision 13871)
+++ /main/waeup.kofa/trunk/src/waeup/kofa/doctests/batchprocessing_browser.txt	(revision 13872)
@@ -98,4 +98,5 @@
     ['AccessCodeBatch Processor',
     'AccessCode Processor',
+    'ApplicantOnlinePayment Processor',
     'Applicant Processor',
     'ApplicantsContainer Processor',
Index: /main/waeup.kofa/trunk/src/waeup/kofa/students/batching.py
===================================================================
--- /main/waeup.kofa/trunk/src/waeup/kofa/students/batching.py	(revision 13871)
+++ /main/waeup.kofa/trunk/src/waeup/kofa/students/batching.py	(revision 13872)
@@ -827,4 +827,7 @@
         # Requirement added on 19/02/2015: same payment must not exist.
         if self.samePaymentMade(student, obj.p_category, obj.p_session):
+            student.__parent__.logger.info(
+                '%s - %s - previous update cancelled'
+                % (self.name, student.student_id))
             raise DuplicationError('Same payment has already been made.')
         if len(p_id.split('-')) != 3 and not p_id.startswith('p'):
Index: /main/waeup.kofa/trunk/src/waeup/kofa/students/tests/test_batching.py
===================================================================
--- /main/waeup.kofa/trunk/src/waeup/kofa/students/tests/test_batching.py	(revision 13871)
+++ /main/waeup.kofa/trunk/src/waeup/kofa/students/tests/test_batching.py	(revision 13872)
@@ -1060,4 +1060,9 @@
             DuplicationError, self.processor.addEntry, payment3,
             dict(student_id=self.student.student_id, p_id='p456'), self.app)
+        logcontent = open(self.logfile).read()
+        self.assertTrue(
+            'INFO - system - StudentOnlinePayment Processor - K1000000 - '
+            'previous update cancelled'
+            in logcontent)
 
     def test_checkConversion(self):
