From fb84165d09f860450d4020d64e264027ea813d20 Mon Sep 17 00:00:00 2001
From: Nicolas ROCHE <nroche@entrouvert.com>
Date: Fri, 21 May 2021 19:39:27 +0200
Subject: [PATCH] caluire-axel: add pay_invoice endpoint (#53963)

---
 passerelle/contrib/caluire_axel/models.py     |  59 +++++++
 passerelle/contrib/caluire_axel/schemas.py    |  11 ++
 .../caluire_axel/xsd/Q_SetPaiement.xsd        |  36 ++++
 .../caluire_axel/xsd/R_SetPaiement.xsd        |  34 ++++
 tests/test_caluire_axel.py                    | 159 ++++++++++++++----
 5 files changed, 265 insertions(+), 34 deletions(-)
 create mode 100644 passerelle/contrib/caluire_axel/xsd/Q_SetPaiement.xsd
 create mode 100644 passerelle/contrib/caluire_axel/xsd/R_SetPaiement.xsd

diff --git a/passerelle/contrib/caluire_axel/models.py b/passerelle/contrib/caluire_axel/models.py
index c618af39..e00324ff 100644
--- a/passerelle/contrib/caluire_axel/models.py
+++ b/passerelle/contrib/caluire_axel/models.py
@@ -20,16 +20,17 @@ import datetime
 from django.core.cache import cache
 from django.db import models
 from django.http import HttpResponse
 from django.utils import dateformat
 from django.utils.timezone import now
 from django.utils.translation import ugettext_lazy as _
 
 from passerelle.base.models import BaseResource
+from passerelle.compat import json_loads
 from passerelle.contrib.utils import axel
 from passerelle.utils.api import endpoint
 from passerelle.utils.jsonresponse import APIError
 
 from . import schemas, utils
 
 
 class CaluireAxel(BaseResource):
@@ -742,16 +743,74 @@ class CaluireAxel(BaseResource):
         )
         if not b64content:
             raise APIError('PDF error', err_code='error', http_status=404)
         response = HttpResponse(content_type='application/pdf')
         response['Content-Disposition'] = 'attachment; filename="%s.pdf"' % invoice_id
         response.write(b64content)
         return response
 
+    @endpoint(
+        display_category=_('Invoices'),
+        display_order=5,
+        name='regie',
+        methods=['post'],
+        perm='can_access',
+        pattern=r'^(?P<regie_id>[\w-]+)/invoice/(?P<invoice_id>\w+-\d+)/pay/?$',
+        example_pattern='{regie_id}/invoice/{invoice_id}/pay',
+        description=_('Notify an invoice as paid'),
+        parameters={
+            'regie_id': {'description': _('Regie identifier'), 'example_value': 'ENF'},
+            'invoice_id': {'description': _('Invoice identifier'), 'example_value': 'IDFAM-42'},
+        },
+        post={
+            'request_body': {
+                'schema': {
+                    'application/json': schemas.PAYMENT_SCHEMA,
+                }
+            }
+        },
+    )
+    def pay_invoice(self, request, regie_id, invoice_id, **kwargs):
+        data = json_loads(request.body)
+        family_id, invoice_id = invoice_id.split('-')
+
+        invoice = self.get_invoice(regie_id=regie_id, family_id=family_id, invoice_id=invoice_id)
+        if invoice is None:
+            raise APIError('Invoice not found', err_code='not-found')
+
+        transaction_amount = 42.00  # invoice['amount']
+        payment_mode_id = data['payment_mode_id']
+        post_data = {
+            'IDFACTURE': int(invoice_id),
+            'IDENTREGIEENC': regie_id,
+            'MONTANT': transaction_amount,
+            'IDENTMODEREGLEMENT': payment_mode_id,
+        }
+        try:
+            result = schemas.set_paiement(self, {'PORTAIL': {'SETPAIEMENT': post_data}})
+        except axel.AxelError as e:
+            raise APIError(
+                'Axel error: %s' % e,
+                err_code='error',
+                data={'xml_request': e.xml_request, 'xml_response': e.xml_response},
+            )
+
+        code = result.json_response['DATA']['PORTAIL']['SETPAIEMENT']['CODE']
+        if code < 0:
+            raise APIError('Wrong pay-invoice status', err_code='pay-invoice-code-error-%s' % code)
+
+        return {
+            'created': True,
+            'data': {
+                'xml_request': result.xml_request,
+                'xml_response': result.xml_response,
+            },
+        }
+
 
 class Link(models.Model):
     resource = models.ForeignKey(CaluireAxel, on_delete=models.CASCADE)
     name_id = models.CharField(blank=False, max_length=256)
     family_id = models.CharField(blank=False, max_length=128)
     person_id = models.CharField(blank=False, max_length=128)
 
     class Meta:
diff --git a/passerelle/contrib/caluire_axel/schemas.py b/passerelle/contrib/caluire_axel/schemas.py
index 939f1f13..2bd683ca 100644
--- a/passerelle/contrib/caluire_axel/schemas.py
+++ b/passerelle/contrib/caluire_axel/schemas.py
@@ -77,16 +77,17 @@ get_famille_individus = Operation('GetFamilleIndividus')
 get_individu = Operation('GetIndividu')
 get_list_ecole = Operation('GetListEcole')
 get_list_activites = Operation('GetListActivites')
 create_inscription_activite = Operation('CreateInscriptionActivite', data_method='setData')
 get_agenda = Operation('GetAgenda')
 get_factures_a_payer = Operation('GetFacturesaPayer')
 get_list_factures = Operation('GetListFactures')
 get_pdf_facture = Operation('GetPdfFacture')
+set_paiement = Operation('SetPaiement', data_method='setData')
 
 
 LINK_SCHEMA = copy.deepcopy(
     find_individus.request_schema['properties']['PORTAIL']['properties']['FINDINDIVIDU']
 )
 for key in ['NAISSANCE', 'CODEPOSTAL', 'VILLE', 'TEL', 'MAIL']:
     LINK_SCHEMA['properties'].pop(key)
     LINK_SCHEMA['required'].remove(key)
@@ -112,8 +113,18 @@ REGISTER_ACTIVITY_SCHEMA = {
     },
     'required': [
         'child_id',
         'activity_id',
         'registration_start_date',
         'registration_end_date',
     ],
 }
+
+PAYMENT_SCHEMA = {
+    'type': 'object',
+    'properties': {
+        'payment_mode_id': {
+            'type': 'string',
+        },
+    },
+    'required': ['payment_mode_id'],
+}
diff --git a/passerelle/contrib/caluire_axel/xsd/Q_SetPaiement.xsd b/passerelle/contrib/caluire_axel/xsd/Q_SetPaiement.xsd
new file mode 100644
index 00000000..3b67f11f
--- /dev/null
+++ b/passerelle/contrib/caluire_axel/xsd/Q_SetPaiement.xsd
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:all="urn:AllAxelTypes">
+	
+	<xsd:import schemaLocation="./AllAxelTypes.xsd" namespace="urn:AllAxelTypes"  />
+	
+	<xsd:complexType name="SETPAIEMENTType">		
+		<xsd:sequence>
+			<xsd:element ref="IDFACTURE"/>
+			<xsd:element ref="IDENTREGIEENC"/>			
+			<xsd:element ref="MONTANT"/>
+			<xsd:element ref="IDENTMODEREGLEMENT"/>			
+		</xsd:sequence>
+	</xsd:complexType>
+	
+	<xsd:complexType name="PORTAILType">
+		<xsd:sequence>
+			<xsd:element ref="SETPAIEMENT" />
+		</xsd:sequence> 
+	</xsd:complexType>
+	
+	<xsd:simpleType name="CODEType">
+		<xsd:restriction base="xsd:string">
+			<xsd:maxLength value="4" />
+		</xsd:restriction>
+	</xsd:simpleType>
+					
+	<xsd:element name="IDFACTURE" type="xsd:positiveInteger"/>
+	<xsd:element name="IDENTREGIEENC" type="all:IDREQUIREDType"/>	
+	<xsd:element name="MONTANT" type="all:MONTANTType"/>	
+	<xsd:element name="IDENTMODEREGLEMENT" type="CODEType"/>	
+	
+	<xsd:element name="SETPAIEMENT" type="SETPAIEMENTType"/>
+	
+	<xsd:element name="PORTAIL" type="PORTAILType"/>	
+		
+</xsd:schema>
\ No newline at end of file
diff --git a/passerelle/contrib/caluire_axel/xsd/R_SetPaiement.xsd b/passerelle/contrib/caluire_axel/xsd/R_SetPaiement.xsd
new file mode 100644
index 00000000..3154a750
--- /dev/null
+++ b/passerelle/contrib/caluire_axel/xsd/R_SetPaiement.xsd
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<xsd:schema	xmlns:all="urn:AllAxelTypes" xmlns:ind="urn:Individu"  xmlns:xsd="http://www.w3.org/2001/XMLSchema" >
+	
+	<xsd:import schemaLocation="./AllAxelTypes.xsd" namespace="urn:AllAxelTypes" />
+	
+	<xsd:redefine schemaLocation="./R_ShemaResultat.xsd">
+	    <xsd:simpleType name="TYPEType">
+			<xsd:restriction base="TYPEType">
+				<xsd:enumeration value="SetPaiement" />
+			</xsd:restriction>
+	    </xsd:simpleType>
+		
+		<xsd:complexType name="PORTAILType">
+			<xsd:complexContent>
+				<xsd:extension base="PORTAILType">
+					<xsd:sequence>
+							<xsd:element ref="SETPAIEMENT" minOccurs="0" maxOccurs="1"/>
+					</xsd:sequence>
+				</xsd:extension>
+			 </xsd:complexContent>
+		</xsd:complexType>
+	</xsd:redefine>	
+		
+	<xsd:complexType name="SETPAIEMENTType">
+		<xsd:sequence>
+			<xsd:element ref="CODE" />
+		</xsd:sequence> 
+	</xsd:complexType>
+	
+	<xsd:element name="CODE" type="xsd:integer"/>
+	
+	<xsd:element name="SETPAIEMENT" type="SETPAIEMENTType"/>
+		
+</xsd:schema>
\ No newline at end of file
diff --git a/tests/test_caluire_axel.py b/tests/test_caluire_axel.py
index c4f0c428..74163fd1 100644
--- a/tests/test_caluire_axel.py
+++ b/tests/test_caluire_axel.py
@@ -12,32 +12,47 @@
 # 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 <http://www.gnu.org/licenses/>.
 
 import copy
 import datetime
+import decimal
 import os
 import xml.etree.ElementTree as ET
 from contextlib import contextmanager
 
 import freezegun
 import mock
 import pytest
 import utils
 import xmlschema
 
 from passerelle.contrib.caluire_axel import schemas
 from passerelle.contrib.caluire_axel.models import CaluireAxel, Link
 from passerelle.contrib.utils.axel import AxelError, json_date_format, xml_date_format
 from passerelle.utils.soap import SOAPError
 
 
+XML_RESPONSE_TEMPLATE = '''<?xml version="1.0"?>
+<PORTAILSERVICE>
+  <RESULTAT>
+    <TYPE>%s</TYPE>
+    <STATUS>OK</STATUS>
+    <DATE>10/10/2010 10:10:01</DATE>
+    <COMMENTAIRES><![CDATA[]]></COMMENTAIRES>
+  </RESULTAT>
+  <DATA>
+    %s
+  </DATA>
+</PORTAILSERVICE>'''
+
+
 @pytest.fixture
 def resource(db):
     return utils.make_resource(
         CaluireAxel, slug='test', wsdl_url='http://example.net/AXEL_WS/AxelWS.php?wsdl'
     )
 
 
 @pytest.fixture
@@ -49,33 +64,17 @@ def link_params():
     }
 
 
 @pytest.fixture
 def family_data():
     filepath = os.path.join(os.path.dirname(__file__), 'data/caluire_axel/family_info.xml')
     with open(filepath) as xml:
         content = xml.read()
-    resp = (
-        '''
-    <?xml version="1.0"?>
-    <PORTAILSERVICE>
-      <RESULTAT>
-        <TYPE>GetFamilleIndividus</TYPE>
-        <STATUS>OK</STATUS>
-        <DATE>10/10/2010 10:10:01</DATE>
-        <COMMENTAIRES><![CDATA[]]></COMMENTAIRES>
-      </RESULTAT>
-      <DATA>
-        %s
-      </DATA>
-    </PORTAILSERVICE>
-    '''.strip()
-        % content
-    )
+    resp = XML_RESPONSE_TEMPLATE % ('GetFamilleIndividus', content)
     return schemas.get_famille_individus.response_converter.decode(ET.fromstring(resp))['DATA']['PORTAIL'][
         'GETFAMILLE'
     ]
 
 
 @pytest.fixture
 def register_activity_params():
     return {
@@ -84,33 +83,17 @@ def register_activity_params():
         'registration_start_date': '2020-09-01',
         'registration_end_date': '2021-08-31',
     }
 
 
 @contextmanager
 def mock_data(content, operation, data_method='getData'):
     with mock.patch('passerelle.contrib.caluire_axel.models.CaluireAxel.soap_client') as client:
-        resp = '''
-        <?xml version="1.0"?>
-        <PORTAILSERVICE>
-          <RESULTAT>
-            <TYPE>%s</TYPE>
-            <STATUS>OK</STATUS>
-            <DATE>10/10/2010 10:10:01</DATE>
-            <COMMENTAIRES><![CDATA[]]></COMMENTAIRES>
-          </RESULTAT>
-          <DATA>
-            %s
-          </DATA>
-        </PORTAILSERVICE>
-        '''.strip() % (
-            operation,
-            content,
-        )
+        resp = XML_RESPONSE_TEMPLATE % (operation, content)
         getattr(client.return_value.service, data_method).return_value = resp
         yield
 
 
 def test_operation_status_error(resource):
     resp = '''
     <?xml version="1.0"?>
     <PORTAILSERVICE>
@@ -235,16 +218,17 @@ def test_link_endpoint_axel_error(app, resource, link_params):
     <COMMENTAIRES />
   </RESULTAT>
   <DATA>
     %s
   </DATA>
 </PORTAILSERVICE>"""
         % content
     )
+
     with mock_data(content, 'FindIndividus'):
         with mock.patch('passerelle.contrib.utils.axel.AxelSchema.decode') as decode:
             decode.side_effect = xmlschema.XMLSchemaValidationError(None, None)
             resp = app.post_json('/caluire-axel/test/link?NameID=yyy', params=link_params)
     assert resp.json['err_desc'].startswith("Axel error: invalid response")
     assert resp.json['err'] == 'error'
     assert resp.json['data']['xml_request'] == xml_request
     assert resp.json['data']['xml_response'] == xml_response
@@ -1636,8 +1620,115 @@ def test_invoice_pdf_endpoint(app, resource):
             app.get('/caluire-axel/test/regie/MAREGIE/invoice/XXX-42/pdf?NameID=yyy')
     assert invoice.call_args_list[0][1]['historical'] is False
 
     with mock.patch('passerelle.contrib.caluire_axel.models.CaluireAxel.get_invoice') as invoice:
         invoice.return_value = {'has_pdf': True, 'display_id': '42'}
         with mock_data(pdf_content, 'GetPdfFacture'):
             app.get('/caluire-axel/test/regie/MAREGIE/invoice/historical-XXX-42/pdf?NameID=yyy')
     assert invoice.call_args_list[0][1]['historical'] is True
+
+
+def test_pay_invoice_endpoint_axel_error(app, resource):
+    payload = {
+        'payment_mode_id': '',
+    }
+    Link.objects.create(resource=resource, name_id='yyy', family_id='XXX', person_id='42')
+    with mock.patch('passerelle.contrib.caluire_axel.schemas.get_factures_a_payer') as operation:
+        operation.side_effect = AxelError('FooBar')
+        resp = app.post_json('/caluire-axel/test/regie/MAREGIE/invoice/XXX-42/pay?NameID=yyy', params=payload)
+    assert resp.json['err_desc'] == "Axel error: FooBar"
+    assert resp.json['err'] == 'error'
+
+    filepath = os.path.join(os.path.dirname(__file__), 'data/caluire_axel/invoices.xml')
+    with open(filepath) as xml:
+        content = (
+            '''<PORTAIL>
+  <GETFACTURESAPAYER>
+    %s
+  </GETFACTURESAPAYER>
+</PORTAIL>'''
+            % xml.read()
+        )
+    with mock_data(content, 'GetFacturesaPayer'):
+        with mock.patch('passerelle.contrib.caluire_axel.schemas.set_paiement') as operation:
+            operation.side_effect = AxelError('FooBar')
+            resp = app.post_json(
+                '/caluire-axel/test/regie/MAREGIE/invoice/XXX-42/pay?NameID=yyy', params=payload
+            )
+    assert resp.json['err_desc'] == "Axel error: FooBar"
+    assert resp.json['err'] == 'error'
+
+    content2 = '''
+<PORTAIL>
+  <SETPAIEMENT>
+    <CODE>-3</CODE>
+  </SETPAIEMENT>
+</PORTAIL>'''
+    with mock.patch('passerelle.contrib.caluire_axel.models.CaluireAxel.soap_client') as client:
+        client.return_value.service.getData.return_value = XML_RESPONSE_TEMPLATE % (
+            'GetFacturesaPayer',
+            content,
+        )
+        client.return_value.service.setData.return_value = XML_RESPONSE_TEMPLATE % ('SetPaiement', content2)
+        resp = app.post_json('/caluire-axel/test/regie/MAREGIE/invoice/XXX-42/pay?NameID=yyy', params=payload)
+    assert resp.json['err_desc'] == "Wrong pay-invoice status"
+    assert resp.json['err'] == 'pay-invoice-code-error--3'
+
+
+def test_pay_invoice_endpoint_bad_request(app, resource):
+    resp = app.post_json(
+        '/caluire-axel/test/regie/MAREGIE/invoice/XXX-42/pay?NameID=yyy', params={}, status=400
+    )
+    assert resp.json['err_desc'] == "'payment_mode_id' is a required property"
+
+
+def test_pay_invoice_endpoint_no_result(app, resource):
+    payload = {
+        'payment_mode_id': '',
+    }
+    filepath = os.path.join(os.path.dirname(__file__), 'data/caluire_axel/invoices.xml')
+    with open(filepath) as xml:
+        content = (
+            '''<PORTAIL>
+  <GETFACTURESAPAYER>
+    %s
+  </GETFACTURESAPAYER>
+</PORTAIL>'''
+            % xml.read()
+        )
+    with mock_data(content, 'GetFacturesaPayer'):
+        resp = app.post_json('/caluire-axel/test/regie/MAREGIE/invoice/XXX-35/pay?NameID=yyy', params=payload)
+    assert resp.json['err_desc'] == "Invoice not found"
+    assert resp.json['err'] == 'not-found'
+
+
+def test_pay_invoice_endpoint(app, resource):
+    payload = {
+        'payment_mode_id': '',
+    }
+    Link.objects.create(resource=resource, name_id='yyy', family_id='XXX', person_id='42')
+    filepath = os.path.join(os.path.dirname(__file__), 'data/caluire_axel/invoices.xml')
+    with open(filepath) as xml:
+        content = (
+            '''
+<PORTAIL>
+  <GETFACTURESAPAYER>
+    %s
+  </GETFACTURESAPAYER>
+</PORTAIL>'''
+            % xml.read()
+        )
+    content2 = '''
+<PORTAIL>
+  <SETPAIEMENT>
+    <CODE>0</CODE>
+  </SETPAIEMENT>
+</PORTAIL>'''
+    with mock.patch('passerelle.contrib.caluire_axel.models.CaluireAxel.soap_client') as client:
+        client.return_value.service.getData.return_value = XML_RESPONSE_TEMPLATE % (
+            'GetFacturesaPayer',
+            content,
+        )
+        client.return_value.service.setData.return_value = XML_RESPONSE_TEMPLATE % ('SetPaiement', content2)
+        resp = app.post_json('/caluire-axel/test/regie/MAREGIE/invoice/XXX-42/pay?NameID=yyy', params=payload)
+    assert resp.json['err'] == 0
+    assert resp.json['created'] is True
-- 
2.30.2

