├── .gitignore ├── .gitlab-ci.yml ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── requirements_dev.txt ├── sepadd └── __init__.py ├── sepaxml ├── __init__.py ├── debit.py ├── schemas │ ├── pain.001.001.03.xsd │ └── pain.008.001.02.xsd ├── shared.py ├── transfer.py ├── utils.py └── validation.py ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── debit ├── __init__.py ├── test_00800102.py ├── test_config.py ├── test_endtoendid.py ├── test_escaped.py ├── test_no_bic.py ├── test_non_batch.py └── test_timestamps.py ├── transfer ├── __init__.py ├── test_00100103.py ├── test_config.py ├── test_domestic.py ├── test_endtoendid.py ├── test_no_bic.py ├── test_non_batch.py └── test_timestamps.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | build/ 3 | dist/ 4 | *.egg-info 5 | env 6 | .idea/ 7 | .pytest_cache 8 | *.pyc 9 | *~ 10 | .cache/ 11 | 12 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | pypi: 2 | image: 3 | name: pretix/ci-image 4 | script: 5 | - cat $PYPIRC > ~/.pypirc 6 | - pip install -U pip uv 7 | - uv pip install --system -U wheel setuptools 8 | - uv pip install --system -Ur requirements_dev.txt 9 | - python setup.py develop 10 | - python setup.py sdist bdist_wheel 11 | - twine check dist/* 12 | - twine upload dist/* 13 | only: 14 | - pypi 15 | artifacts: 16 | paths: 17 | - dist/ 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | python: 4 | - "3.6" 5 | - "3.7" 6 | - "3.8" 7 | install: 8 | - pip install -U pip wheel coverage codecov 9 | - pip install -r requirements_dev.txt 10 | - python setup.py develop 11 | script: 12 | - flake8 sepaxml tests 13 | - isort -rc -c sepaxml 14 | - coverage run -m py.test -v tests/ && codecov 15 | cache: 16 | directories: 17 | - $HOME/.cache/pip 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Congressus, The Netherlands 2 | Copyright (c) 2017-2023 Raphael Michel and contributors 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is furnished 9 | to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 18 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include sepaxml/schemas * 2 | include LICENSE 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | SEPA XML Generator 2 | ================== 3 | 4 | .. image:: https://travis-ci.org/raphaelm/python-sepaxml.svg?branch=master 5 | :target: https://travis-ci.org/raphaelm/python-sepaxml 6 | 7 | .. image:: https://codecov.io/gh/raphaelm/python-sepaxml/branch/master/graph/badge.svg 8 | :target: https://codecov.io/gh/raphaelm/python-sepaxml 9 | 10 | .. image:: http://img.shields.io/pypi/v/sepaxml.svg 11 | :target: https://pypi.python.org/pypi/sepaxml 12 | 13 | This is a python implementation to generate SEPA XML files. 14 | 15 | Limitations 16 | ----------- 17 | 18 | Supported standards: 19 | 20 | * SEPA PAIN.001.001.03 21 | * SEPA PAIN.008.001.02 22 | 23 | Usage 24 | ----- 25 | 26 | Direct debit 27 | """""""""""" 28 | 29 | Example: 30 | 31 | .. code:: python 32 | 33 | from sepaxml import SepaDD 34 | import datetime, uuid 35 | 36 | config = { 37 | "name": "Test von Testenstein", 38 | "IBAN": "NL50BANK1234567890", 39 | "BIC": "BANKNL2A", 40 | "batch": True, 41 | "creditor_id": "DE26ZZZ00000000000", # supplied by your bank or financial authority 42 | "currency": "EUR", # ISO 4217 43 | # "instrument": "B2B", # - default is CORE (B2C) 44 | "address": { 45 | # The address and all of its fields are optional but in some countries they are required 46 | "address_type": "ADDR", # valid: ADDR, PBOX, HOME, BIZZ, MLTO, DLVY 47 | "department": "Head Office", 48 | "subdepartment": None, 49 | "street_name": "Musterstr.", 50 | "building_number": "1", 51 | "postcode": "12345", 52 | "town": "Berlin", 53 | "country": "DE", 54 | "country_subdivision": None, 55 | "lines": ["Line 1", "Line 2"], 56 | }, 57 | } 58 | sepa = SepaDD(config, schema="pain.008.001.02", clean=True) 59 | 60 | payment = { 61 | "name": "Test von Testenstein", 62 | "IBAN": "NL50BANK1234567890", 63 | "BIC": "BANKNL2A", 64 | "amount": 5000, # in cents 65 | "type": "RCUR", # FRST,RCUR,OOFF,FNAL 66 | "collection_date": datetime.date.today(), 67 | "mandate_id": "1234", 68 | "mandate_date": datetime.date.today(), 69 | "description": "Test transaction", 70 | # "endtoend_id": str(uuid.uuid1()).replace("-", ""), # autogenerated if obmitted 71 | "address": { 72 | # The address and all of its fields are optional but in some countries they are required 73 | "address_type": "ADDR", # valid: ADDR, PBOX, HOME, BIZZ, MLTO, DLVY 74 | "department": "Head Office", 75 | "subdepartment": None, 76 | "street_name": "Musterstr.", 77 | "building_number": "1", 78 | "postcode": "12345", 79 | "town": "Berlin", 80 | "country": "DE", 81 | "country_subdivision": None, 82 | "lines": ["Line 1", "Line 2"], 83 | }, 84 | } 85 | sepa.add_payment(payment) 86 | 87 | print(sepa.export(validate=True)) 88 | 89 | 90 | Credit transfer 91 | """"""""""""""" 92 | 93 | Example: 94 | 95 | .. code:: python 96 | 97 | from sepaxml import SepaTransfer 98 | import datetime, uuid 99 | 100 | config = { 101 | "name": "Test von Testenstein", 102 | "IBAN": "NL50BANK1234567890", 103 | "BIC": "BANKNL2A", 104 | "batch": True, 105 | # For non-SEPA transfers, set "domestic" to True, necessary e.g. for CH/LI 106 | "currency": "EUR", # ISO 4217 107 | "address": { 108 | # The address and all of its fields are optional but in some countries they are required 109 | "address_type": "ADDR", # valid: ADDR, PBOX, HOME, BIZZ, MLTO, DLVY 110 | "department": "Head Office", 111 | "subdepartment": None, 112 | "street_name": "Musterstr.", 113 | "building_number": "1", 114 | "postcode": "12345", 115 | "town": "Berlin", 116 | "country": "DE", 117 | "country_subdivision": None, 118 | "lines": ["Line 1", "Line 2"], 119 | }, 120 | } 121 | sepa = SepaTransfer(config, clean=True) 122 | 123 | payment = { 124 | "name": "Test von Testenstein", 125 | "IBAN": "NL50BANK1234567890", 126 | "BIC": "BANKNL2A", 127 | "amount": 5000, # in cents 128 | "execution_date": datetime.date.today() + datetime.timedelta(days=2), 129 | "description": "Test transaction", 130 | # "endtoend_id": str(uuid.uuid1()).replace("-", ""), # optional 131 | "address": { 132 | # The address and all of its fields are optional but in some countries they are required 133 | "address_type": "ADDR", # valid: ADDR, PBOX, HOME, BIZZ, MLTO, DLVY 134 | "department": "Head Office", 135 | "subdepartment": None, 136 | "street_name": "Musterstr.", 137 | "building_number": "1", 138 | "postcode": "12345", 139 | "town": "Berlin", 140 | "country": "DE", 141 | "country_subdivision": None, 142 | "lines": ["Line 1", "Line 2"], 143 | }, 144 | } 145 | sepa.add_payment(payment) 146 | 147 | print(sepa.export(validate=True)) 148 | 149 | 150 | Development 151 | ----------- 152 | 153 | To run the included tests:: 154 | 155 | pip install -r requirements_dev.txt 156 | py.test tests 157 | 158 | To automatically sort your Imports as required by CI:: 159 | 160 | pip install isort 161 | isort -rc . 162 | 163 | 164 | Security 165 | -------- 166 | 167 | If you discover a security issue, please contact us at security@pretix.eu and see our `Responsible Disclosure Policy`_ further information. 168 | 169 | Credits and License 170 | ------------------- 171 | 172 | Maintainer: Raphael Michel 173 | 174 | This basically started as a properly packaged, python 3 tested version 175 | of the `PySepaDD`_ implementation that was released by The Congressus under the MIT license. 176 | Thanks for your work! 177 | 178 | The source code is released under MIT license. 179 | 180 | Not part of the MIT-licensed project are the XML schemas in the ``sepaxml/schemas/`` 181 | folder which are copyrighted by the ISO 20022 organization but `allowed to be reproduced`_ 182 | freely. 183 | 184 | .. _PySepaDD: https://github.com/congressus/PySepaDD 185 | .. _allowed to be reproduced: https://www.iso20022.org/terms-use 186 | .. _Responsible Disclosure Policy: https://docs.pretix.eu/trust/security/disclosure/ 187 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | lxml 2 | pytest 3 | flake8 4 | isort 5 | -------------------------------------------------------------------------------- /sepadd/__init__.py: -------------------------------------------------------------------------------- 1 | from sepaxml import SepaDD # noqa 2 | -------------------------------------------------------------------------------- /sepaxml/__init__.py: -------------------------------------------------------------------------------- 1 | from .debit import SepaDD # noqa 2 | from .transfer import SepaTransfer # noqa 3 | 4 | version = '2.6.2' 5 | -------------------------------------------------------------------------------- /sepaxml/debit.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2014 Congressus, The Netherlands 3 | Copyright (c) 2017-2023 Raphael Michel and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is furnished 10 | to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 19 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | """ 22 | import datetime 23 | import xml.etree.ElementTree as ET 24 | 25 | from .shared import SepaPaymentInitn 26 | from .utils import ADDRESS_MAPPING, int_to_decimal_str, make_id 27 | 28 | 29 | class SepaDD(SepaPaymentInitn): 30 | """ 31 | This class creates a Sepa Direct Debit XML File. 32 | """ 33 | root_el = "CstmrDrctDbtInitn" 34 | 35 | def __init__(self, config, schema="pain.008.001.02", clean=True): 36 | if "instrument" not in config: 37 | config["instrument"] = "CORE" 38 | super().__init__(config, schema, clean) 39 | 40 | def check_config(self, config): 41 | """ 42 | Check the config file for required fields and validity. 43 | @param config: The config dict. 44 | @return: True if valid, error string if invalid paramaters where 45 | encountered. 46 | """ 47 | validation = "" 48 | required = ["name", "IBAN", "batch", "creditor_id", "currency"] 49 | if self.schema == 'pain.008.001.02': 50 | required += ["BIC"] 51 | 52 | for config_item in required: 53 | if config_item not in config: 54 | validation += config_item.upper() + "_MISSING " 55 | 56 | if not validation: 57 | return True 58 | else: 59 | raise Exception("Config file did not validate. " + validation) 60 | 61 | def check_payment(self, payment): 62 | """ 63 | Check the payment for required fields and validity. 64 | @param payment: The payment dict 65 | @return: True if valid, error string if invalid paramaters where 66 | encountered. 67 | """ 68 | validation = "" 69 | 70 | if not isinstance(payment['amount'], int): 71 | validation += "AMOUNT_NOT_INTEGER " 72 | 73 | if not isinstance(payment['mandate_date'], datetime.date): 74 | validation += "MANDATE_DATE_INVALID_OR_NOT_DATETIME_INSTANCE" 75 | payment['mandate_date'] = str(payment['mandate_date']) 76 | 77 | if not isinstance(payment['collection_date'], datetime.date): 78 | validation += "COLLECTION_DATE_INVALID_OR_NOT_DATETIME_INSTANCE" 79 | payment['collection_date'] = str(payment['collection_date']) 80 | 81 | if validation == "": 82 | return True 83 | else: 84 | raise Exception('Payment did not validate: ' + validation) 85 | 86 | def add_payment(self, payment): 87 | """ 88 | Function to add payments 89 | @param payment: The payment dict 90 | @raise exception: when payment is invalid 91 | """ 92 | if self.clean: 93 | from text_unidecode import unidecode 94 | 95 | payment['name'] = unidecode(payment['name'])[:70] 96 | payment['description'] = unidecode(payment['description'])[:140] 97 | 98 | # Validate the payment 99 | self.check_payment(payment) 100 | 101 | # Get the CstmrDrctDbtInitnNode 102 | if not self._config['batch']: 103 | # Start building the non batch payment 104 | PmtInf_nodes = self._create_PmtInf_node() 105 | PmtInf_nodes['PmtInfIdNode'].text = make_id(self._config['name']) 106 | PmtInf_nodes['PmtMtdNode'].text = "DD" 107 | PmtInf_nodes['BtchBookgNode'].text = "false" 108 | PmtInf_nodes['NbOfTxsNode'].text = "1" 109 | PmtInf_nodes['CtrlSumNode'].text = int_to_decimal_str( 110 | payment['amount']) 111 | PmtInf_nodes['Cd_SvcLvl_Node'].text = "SEPA" 112 | PmtInf_nodes['Cd_LclInstrm_Node'].text = self._config['instrument'] 113 | PmtInf_nodes['SeqTpNode'].text = payment['type'] 114 | PmtInf_nodes['ReqdColltnDtNode'].text = payment['collection_date'] 115 | PmtInf_nodes['Nm_Cdtr_Node'].text = self._config['name'] 116 | PmtInf_nodes['IBAN_CdtrAcct_Node'].text = self._config['IBAN'] 117 | 118 | if 'BIC' in self._config: 119 | PmtInf_nodes['BIC_CdtrAgt_Node'].text = self._config['BIC'] 120 | else: 121 | PmtInf_nodes['Id_CdtrAgt_Node'].text = "NOTPROVIDED" 122 | 123 | PmtInf_nodes['ChrgBrNode'].text = "SLEV" 124 | PmtInf_nodes['Id_Othr_Node'].text = self._config['creditor_id'] 125 | PmtInf_nodes['PrtryNode'].text = "SEPA" 126 | 127 | if 'BIC' in payment: 128 | bic = True 129 | else: 130 | bic = False 131 | 132 | TX_nodes = self._create_TX_node(bic) 133 | TX_nodes['InstdAmtNode'].set("Ccy", self._config['currency']) 134 | TX_nodes['InstdAmtNode'].text = int_to_decimal_str(payment['amount']) 135 | 136 | TX_nodes['MndtIdNode'].text = payment['mandate_id'] 137 | TX_nodes['DtOfSgntrNode'].text = payment['mandate_date'] 138 | if bic: 139 | TX_nodes['BIC_DbtrAgt_Node'].text = payment['BIC'] 140 | else: 141 | TX_nodes['Id_DbtrAgt_Node'].text = "NOTPROVIDED" 142 | 143 | TX_nodes['Nm_Dbtr_Node'].text = payment['name'] 144 | if payment.get('address', {}): 145 | for d, n in ADDRESS_MAPPING: 146 | if payment['address'].get(d): 147 | n = ET.Element(n) 148 | n.text = payment['address'][d] 149 | TX_nodes['PstlAdr_Dbtr_Node'].append(n) 150 | for line in payment['address'].get('lines', []): 151 | n = ET.Element('AdrLine') 152 | n.text = line 153 | TX_nodes['PstlAdr_Dbtr_Node'].append(n) 154 | 155 | TX_nodes['IBAN_DbtrAcct_Node'].text = payment['IBAN'] 156 | TX_nodes['UstrdNode'].text = payment['description'] 157 | if not payment.get('endtoend_id', ''): 158 | payment['endtoend_id'] = make_id(self._config['name']) 159 | TX_nodes['EndToEndIdNode'].text = payment['endtoend_id'] 160 | 161 | if self._config['batch']: 162 | self._add_batch(TX_nodes, payment) 163 | else: 164 | self._add_non_batch(TX_nodes, PmtInf_nodes) 165 | 166 | def _create_header(self): 167 | """ 168 | Function to create the GroupHeader (GrpHdr) in the 169 | CstmrDrctDbtInit Node 170 | """ 171 | # Retrieve the node to which we will append the group header. 172 | CstmrDrctDbtInitn_node = self._xml.find('CstmrDrctDbtInitn') 173 | 174 | # Create the header nodes. 175 | GrpHdr_node = ET.Element("GrpHdr") 176 | MsgId_node = ET.Element("MsgId") 177 | CreDtTm_node = ET.Element("CreDtTm") 178 | NbOfTxs_node = ET.Element("NbOfTxs") 179 | CtrlSum_node = ET.Element("CtrlSum") 180 | InitgPty_node = ET.Element("InitgPty") 181 | Nm_node = ET.Element("Nm") 182 | SupId_node = ET.Element("Id") 183 | OrgId_node = ET.Element("OrgId") 184 | Othr_node = ET.Element("Othr") 185 | Id_node = ET.Element("Id") 186 | 187 | # Add data to some header nodes. 188 | MsgId_node.text = self.msg_id 189 | CreDtTm_node.text = datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S') 190 | Nm_node.text = self._config['name'] 191 | Id_node.text = self._config['creditor_id'] 192 | 193 | # Append the nodes 194 | Othr_node.append(Id_node) 195 | OrgId_node.append(Othr_node) 196 | SupId_node.append(OrgId_node) 197 | InitgPty_node.append(Nm_node) 198 | InitgPty_node.append(SupId_node) 199 | GrpHdr_node.append(MsgId_node) 200 | GrpHdr_node.append(CreDtTm_node) 201 | GrpHdr_node.append(NbOfTxs_node) 202 | GrpHdr_node.append(CtrlSum_node) 203 | GrpHdr_node.append(InitgPty_node) 204 | 205 | # Append the header to its parent 206 | CstmrDrctDbtInitn_node.append(GrpHdr_node) 207 | 208 | def _create_PmtInf_node(self): 209 | """ 210 | Method to create the blank payment information nodes as a dict. 211 | """ 212 | ED = dict() # ED is element dict 213 | ED['PmtInfNode'] = ET.Element("PmtInf") 214 | ED['PmtInfIdNode'] = ET.Element("PmtInfId") 215 | ED['PmtMtdNode'] = ET.Element("PmtMtd") 216 | ED['BtchBookgNode'] = ET.Element("BtchBookg") 217 | ED['NbOfTxsNode'] = ET.Element("NbOfTxs") 218 | ED['CtrlSumNode'] = ET.Element("CtrlSum") 219 | ED['PmtTpInfNode'] = ET.Element("PmtTpInf") 220 | ED['SvcLvlNode'] = ET.Element("SvcLvl") 221 | ED['Cd_SvcLvl_Node'] = ET.Element("Cd") 222 | ED['LclInstrmNode'] = ET.Element("LclInstrm") 223 | ED['Cd_LclInstrm_Node'] = ET.Element("Cd") 224 | ED['SeqTpNode'] = ET.Element("SeqTp") 225 | ED['ReqdColltnDtNode'] = ET.Element("ReqdColltnDt") 226 | ED['CdtrNode'] = ET.Element("Cdtr") 227 | ED['Nm_Cdtr_Node'] = ET.Element("Nm") 228 | ED['PstlAdr_Cdtr_Node'] = ET.Element("PstlAdr") 229 | ED['CdtrAcctNode'] = ET.Element("CdtrAcct") 230 | ED['Id_CdtrAcct_Node'] = ET.Element("Id") 231 | ED['IBAN_CdtrAcct_Node'] = ET.Element("IBAN") 232 | ED['CdtrAgtNode'] = ET.Element("CdtrAgt") 233 | ED['FinInstnId_CdtrAgt_Node'] = ET.Element("FinInstnId") 234 | if 'BIC' in self._config: 235 | ED['BIC_CdtrAgt_Node'] = ET.Element("BIC") 236 | else: 237 | ED['Othr_CdtrAgt_Node'] = ET.Element("Othr") 238 | ED['Id_CdtrAgt_Node'] = ET.Element("Id") 239 | ED['ChrgBrNode'] = ET.Element("ChrgBr") 240 | ED['CdtrSchmeIdNode'] = ET.Element("CdtrSchmeId") 241 | ED['Id_CdtrSchmeId_Node'] = ET.Element("Id") 242 | ED['PrvtIdNode'] = ET.Element("PrvtId") 243 | ED['OthrNode'] = ET.Element("Othr") 244 | ED['Id_Othr_Node'] = ET.Element("Id") 245 | ED['SchmeNmNode'] = ET.Element("SchmeNm") 246 | ED['PrtryNode'] = ET.Element("Prtry") 247 | return ED 248 | 249 | def _create_TX_node(self, bic=True): 250 | """ 251 | Method to create the blank transaction nodes as a dict. If bic is True, 252 | the BIC node will also be created. 253 | """ 254 | ED = dict() 255 | ED['DrctDbtTxInfNode'] = ET.Element("DrctDbtTxInf") 256 | ED['PmtIdNode'] = ET.Element("PmtId") 257 | ED['EndToEndIdNode'] = ET.Element("EndToEndId") 258 | ED['InstdAmtNode'] = ET.Element("InstdAmt") 259 | ED['DrctDbtTxNode'] = ET.Element("DrctDbtTx") 260 | ED['MndtRltdInfNode'] = ET.Element("MndtRltdInf") 261 | ED['MndtIdNode'] = ET.Element("MndtId") 262 | ED['DtOfSgntrNode'] = ET.Element("DtOfSgntr") 263 | ED['DbtrAgtNode'] = ET.Element("DbtrAgt") 264 | ED['FinInstnId_DbtrAgt_Node'] = ET.Element("FinInstnId") 265 | if bic: 266 | ED['BIC_DbtrAgt_Node'] = ET.Element("BIC") 267 | else: 268 | ED['Id_DbtrAgt_Node'] = ET.Element("Id") 269 | ED['Othr_DbtrAgt_Node'] = ET.Element("Othr") 270 | ED['DbtrNode'] = ET.Element("Dbtr") 271 | ED['Nm_Dbtr_Node'] = ET.Element("Nm") 272 | ED['PstlAdr_Dbtr_Node'] = ET.Element("PstlAdr") 273 | ED['DbtrAcctNode'] = ET.Element("DbtrAcct") 274 | ED['Id_DbtrAcct_Node'] = ET.Element("Id") 275 | ED['IBAN_DbtrAcct_Node'] = ET.Element("IBAN") 276 | ED['RmtInfNode'] = ET.Element("RmtInf") 277 | ED['UstrdNode'] = ET.Element("Ustrd") 278 | return ED 279 | 280 | def _add_non_batch(self, TX_nodes, PmtInf_nodes): 281 | """ 282 | Method to add a transaction as non batch, will fold the transaction 283 | together with the payment info node and append to the main xml. 284 | """ 285 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['PmtInfIdNode']) 286 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['PmtMtdNode']) 287 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['BtchBookgNode']) 288 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['NbOfTxsNode']) 289 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['CtrlSumNode']) 290 | 291 | PmtInf_nodes['SvcLvlNode'].append(PmtInf_nodes['Cd_SvcLvl_Node']) 292 | PmtInf_nodes['LclInstrmNode'].append(PmtInf_nodes['Cd_LclInstrm_Node']) 293 | PmtInf_nodes['PmtTpInfNode'].append(PmtInf_nodes['SvcLvlNode']) 294 | PmtInf_nodes['PmtTpInfNode'].append(PmtInf_nodes['LclInstrmNode']) 295 | PmtInf_nodes['PmtTpInfNode'].append(PmtInf_nodes['SeqTpNode']) 296 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['PmtTpInfNode']) 297 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['ReqdColltnDtNode']) 298 | 299 | PmtInf_nodes['CdtrNode'].append(PmtInf_nodes['Nm_Cdtr_Node']) 300 | if PmtInf_nodes['PstlAdr_Cdtr_Node']: 301 | PmtInf_nodes['CdtrNode'].append(PmtInf_nodes['PstlAdr_Cdtr_Node']) 302 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['CdtrNode']) 303 | 304 | PmtInf_nodes['Id_CdtrAcct_Node'].append( 305 | PmtInf_nodes['IBAN_CdtrAcct_Node']) 306 | PmtInf_nodes['CdtrAcctNode'].append(PmtInf_nodes['Id_CdtrAcct_Node']) 307 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['CdtrAcctNode']) 308 | 309 | if 'BIC' in self._config: 310 | PmtInf_nodes['FinInstnId_CdtrAgt_Node'].append( 311 | PmtInf_nodes['BIC_CdtrAgt_Node']) 312 | else: 313 | PmtInf_nodes['Othr_CdtrAgt_Node'].append( 314 | PmtInf_nodes['Id_CdtrAgt_Node']) 315 | PmtInf_nodes['FinInstnId_CdtrAgt_Node'].append( 316 | PmtInf_nodes['Othr_CdtrAgt_Node']) 317 | 318 | PmtInf_nodes['CdtrAgtNode'].append( 319 | PmtInf_nodes['FinInstnId_CdtrAgt_Node']) 320 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['CdtrAgtNode']) 321 | 322 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['ChrgBrNode']) 323 | 324 | PmtInf_nodes['OthrNode'].append(PmtInf_nodes['Id_Othr_Node']) 325 | PmtInf_nodes['SchmeNmNode'].append(PmtInf_nodes['PrtryNode']) 326 | PmtInf_nodes['OthrNode'].append(PmtInf_nodes['SchmeNmNode']) 327 | PmtInf_nodes['PrvtIdNode'].append(PmtInf_nodes['OthrNode']) 328 | PmtInf_nodes['Id_CdtrSchmeId_Node'].append(PmtInf_nodes['PrvtIdNode']) 329 | PmtInf_nodes['CdtrSchmeIdNode'].append( 330 | PmtInf_nodes['Id_CdtrSchmeId_Node']) 331 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['CdtrSchmeIdNode']) 332 | 333 | TX_nodes['PmtIdNode'].append(TX_nodes['EndToEndIdNode']) 334 | TX_nodes['DrctDbtTxInfNode'].append(TX_nodes['PmtIdNode']) 335 | TX_nodes['DrctDbtTxInfNode'].append(TX_nodes['InstdAmtNode']) 336 | 337 | TX_nodes['MndtRltdInfNode'].append(TX_nodes['MndtIdNode']) 338 | TX_nodes['MndtRltdInfNode'].append(TX_nodes['DtOfSgntrNode']) 339 | TX_nodes['DrctDbtTxNode'].append(TX_nodes['MndtRltdInfNode']) 340 | TX_nodes['DrctDbtTxInfNode'].append(TX_nodes['DrctDbtTxNode']) 341 | 342 | if 'BIC_DbtrAgt_Node' in TX_nodes and TX_nodes['BIC_DbtrAgt_Node'].text is not None: 343 | TX_nodes['FinInstnId_DbtrAgt_Node'].append( 344 | TX_nodes['BIC_DbtrAgt_Node']) 345 | else: 346 | TX_nodes['Othr_DbtrAgt_Node'].append( 347 | TX_nodes['Id_DbtrAgt_Node']) 348 | TX_nodes['FinInstnId_DbtrAgt_Node'].append( 349 | TX_nodes['Othr_DbtrAgt_Node']) 350 | TX_nodes['DbtrAgtNode'].append(TX_nodes['FinInstnId_DbtrAgt_Node']) 351 | TX_nodes['DrctDbtTxInfNode'].append(TX_nodes['DbtrAgtNode']) 352 | 353 | TX_nodes['DbtrNode'].append(TX_nodes['Nm_Dbtr_Node']) 354 | if TX_nodes['PstlAdr_Dbtr_Node']: 355 | TX_nodes['DbtrNode'].append(TX_nodes['PstlAdr_Dbtr_Node']) 356 | TX_nodes['DrctDbtTxInfNode'].append(TX_nodes['DbtrNode']) 357 | 358 | TX_nodes['Id_DbtrAcct_Node'].append(TX_nodes['IBAN_DbtrAcct_Node']) 359 | TX_nodes['DbtrAcctNode'].append(TX_nodes['Id_DbtrAcct_Node']) 360 | TX_nodes['DrctDbtTxInfNode'].append(TX_nodes['DbtrAcctNode']) 361 | 362 | TX_nodes['RmtInfNode'].append(TX_nodes['UstrdNode']) 363 | TX_nodes['DrctDbtTxInfNode'].append(TX_nodes['RmtInfNode']) 364 | PmtInf_nodes['PmtInfNode'].append(TX_nodes['DrctDbtTxInfNode']) 365 | CstmrDrctDbtInitn_node = self._xml.find('CstmrDrctDbtInitn') 366 | CstmrDrctDbtInitn_node.append(PmtInf_nodes['PmtInfNode']) 367 | 368 | def _add_batch(self, TX_nodes, payment): 369 | """ 370 | Method to add a payment as a batch. The transaction details are already 371 | present. Will fold the nodes accordingly and the call the 372 | _add_to_batch_list function to store the batch. 373 | """ 374 | TX_nodes['PmtIdNode'].append(TX_nodes['EndToEndIdNode']) 375 | TX_nodes['DrctDbtTxInfNode'].append(TX_nodes['PmtIdNode']) 376 | TX_nodes['DrctDbtTxInfNode'].append(TX_nodes['InstdAmtNode']) 377 | 378 | TX_nodes['MndtRltdInfNode'].append(TX_nodes['MndtIdNode']) 379 | TX_nodes['MndtRltdInfNode'].append(TX_nodes['DtOfSgntrNode']) 380 | TX_nodes['DrctDbtTxNode'].append(TX_nodes['MndtRltdInfNode']) 381 | TX_nodes['DrctDbtTxInfNode'].append(TX_nodes['DrctDbtTxNode']) 382 | 383 | if 'BIC_DbtrAgt_Node' in TX_nodes and TX_nodes['BIC_DbtrAgt_Node'].text is not None: 384 | TX_nodes['FinInstnId_DbtrAgt_Node'].append( 385 | TX_nodes['BIC_DbtrAgt_Node']) 386 | else: 387 | TX_nodes['Othr_DbtrAgt_Node'].append( 388 | TX_nodes['Id_DbtrAgt_Node']) 389 | TX_nodes['FinInstnId_DbtrAgt_Node'].append( 390 | TX_nodes['Othr_DbtrAgt_Node']) 391 | TX_nodes['DbtrAgtNode'].append(TX_nodes['FinInstnId_DbtrAgt_Node']) 392 | TX_nodes['DrctDbtTxInfNode'].append(TX_nodes['DbtrAgtNode']) 393 | 394 | TX_nodes['DbtrNode'].append(TX_nodes['Nm_Dbtr_Node']) 395 | if TX_nodes['PstlAdr_Dbtr_Node']: 396 | TX_nodes['DbtrNode'].append(TX_nodes['PstlAdr_Dbtr_Node']) 397 | TX_nodes['DrctDbtTxInfNode'].append(TX_nodes['DbtrNode']) 398 | 399 | TX_nodes['Id_DbtrAcct_Node'].append(TX_nodes['IBAN_DbtrAcct_Node']) 400 | TX_nodes['DbtrAcctNode'].append(TX_nodes['Id_DbtrAcct_Node']) 401 | TX_nodes['DrctDbtTxInfNode'].append(TX_nodes['DbtrAcctNode']) 402 | 403 | TX_nodes['RmtInfNode'].append(TX_nodes['UstrdNode']) 404 | TX_nodes['DrctDbtTxInfNode'].append(TX_nodes['RmtInfNode']) 405 | self._add_to_batch_list(TX_nodes, payment) 406 | 407 | def _add_to_batch_list(self, TX, payment): 408 | """ 409 | Method to add a transaction to the batch list. The correct batch will 410 | be determined by the payment dict and the batch will be created if 411 | not existant. This will also add the payment amount to the respective 412 | batch total. 413 | """ 414 | batch_key = payment['type'] + "::" + payment['collection_date'] 415 | if batch_key in self._batches.keys(): 416 | self._batches[batch_key].append(TX['DrctDbtTxInfNode']) 417 | else: 418 | self._batches[batch_key] = [] 419 | self._batches[batch_key].append(TX['DrctDbtTxInfNode']) 420 | 421 | if batch_key in self._batch_totals: 422 | self._batch_totals[batch_key] += payment['amount'] 423 | else: 424 | self._batch_totals[batch_key] = payment['amount'] 425 | 426 | def _finalize_batch(self): 427 | """ 428 | Method to finalize the batch, this will iterate over the _batches dict 429 | and create a PmtInf node for each batch. The correct information (from 430 | the batch_key and batch_totals) will be inserted and the batch 431 | transaction nodes will be folded. Finally, the batches will be added to 432 | the main XML. 433 | """ 434 | for batch_meta, batch_nodes in self._batches.items(): 435 | batch_meta_split = batch_meta.split("::") 436 | PmtInf_nodes = self._create_PmtInf_node() 437 | PmtInf_nodes['PmtInfIdNode'].text = make_id(self._config['name']) 438 | PmtInf_nodes['PmtMtdNode'].text = "DD" 439 | PmtInf_nodes['BtchBookgNode'].text = "true" 440 | PmtInf_nodes['Cd_SvcLvl_Node'].text = "SEPA" 441 | PmtInf_nodes['Cd_LclInstrm_Node'].text = self._config['instrument'] 442 | PmtInf_nodes['SeqTpNode'].text = batch_meta_split[0] 443 | PmtInf_nodes['ReqdColltnDtNode'].text = batch_meta_split[1] 444 | PmtInf_nodes['Nm_Cdtr_Node'].text = self._config['name'] 445 | if self._config.get('address', {}): 446 | for d, n in ADDRESS_MAPPING: 447 | if self._config['address'].get(d): 448 | n = ET.Element(n) 449 | n.text = self._config['address'][d] 450 | PmtInf_nodes['PstlAdr_Cdtr_Node'].append(n) 451 | for line in self._config['address'].get('lines', []): 452 | n = ET.Element('AdrLine') 453 | n.text = line 454 | PmtInf_nodes['PstlAdr_Cdtr_Node'].append(n) 455 | PmtInf_nodes['IBAN_CdtrAcct_Node'].text = self._config['IBAN'] 456 | 457 | if 'BIC' in self._config: 458 | PmtInf_nodes['BIC_CdtrAgt_Node'].text = self._config['BIC'] 459 | else: 460 | PmtInf_nodes['Id_CdtrAgt_Node'].text = "NOTPROVIDED" 461 | 462 | PmtInf_nodes['ChrgBrNode'].text = "SLEV" 463 | PmtInf_nodes['Id_Othr_Node'].text = self._config['creditor_id'] 464 | PmtInf_nodes['PrtryNode'].text = "SEPA" 465 | 466 | PmtInf_nodes['NbOfTxsNode'].text = str(len(batch_nodes)) 467 | PmtInf_nodes['CtrlSumNode'].text = int_to_decimal_str(self._batch_totals[batch_meta]) 468 | 469 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['PmtInfIdNode']) 470 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['PmtMtdNode']) 471 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['BtchBookgNode']) 472 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['NbOfTxsNode']) 473 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['CtrlSumNode']) 474 | 475 | PmtInf_nodes['SvcLvlNode'].append(PmtInf_nodes['Cd_SvcLvl_Node']) 476 | PmtInf_nodes['LclInstrmNode'].append( 477 | PmtInf_nodes['Cd_LclInstrm_Node']) 478 | PmtInf_nodes['PmtTpInfNode'].append(PmtInf_nodes['SvcLvlNode']) 479 | PmtInf_nodes['PmtTpInfNode'].append(PmtInf_nodes['LclInstrmNode']) 480 | PmtInf_nodes['PmtTpInfNode'].append(PmtInf_nodes['SeqTpNode']) 481 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['PmtTpInfNode']) 482 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['ReqdColltnDtNode']) 483 | 484 | PmtInf_nodes['CdtrNode'].append(PmtInf_nodes['Nm_Cdtr_Node']) 485 | if PmtInf_nodes['PstlAdr_Cdtr_Node']: 486 | PmtInf_nodes['CdtrNode'].append(PmtInf_nodes['PstlAdr_Cdtr_Node']) 487 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['CdtrNode']) 488 | 489 | PmtInf_nodes['Id_CdtrAcct_Node'].append( 490 | PmtInf_nodes['IBAN_CdtrAcct_Node']) 491 | PmtInf_nodes['CdtrAcctNode'].append( 492 | PmtInf_nodes['Id_CdtrAcct_Node']) 493 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['CdtrAcctNode']) 494 | 495 | if 'BIC' in self._config: 496 | PmtInf_nodes['FinInstnId_CdtrAgt_Node'].append( 497 | PmtInf_nodes['BIC_CdtrAgt_Node']) 498 | else: 499 | PmtInf_nodes['Othr_CdtrAgt_Node'].append( 500 | PmtInf_nodes['Id_CdtrAgt_Node']) 501 | PmtInf_nodes['FinInstnId_CdtrAgt_Node'].append( 502 | PmtInf_nodes['Othr_CdtrAgt_Node']) 503 | 504 | PmtInf_nodes['CdtrAgtNode'].append( 505 | PmtInf_nodes['FinInstnId_CdtrAgt_Node']) 506 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['CdtrAgtNode']) 507 | 508 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['ChrgBrNode']) 509 | 510 | PmtInf_nodes['OthrNode'].append(PmtInf_nodes['Id_Othr_Node']) 511 | PmtInf_nodes['SchmeNmNode'].append(PmtInf_nodes['PrtryNode']) 512 | PmtInf_nodes['OthrNode'].append(PmtInf_nodes['SchmeNmNode']) 513 | PmtInf_nodes['PrvtIdNode'].append(PmtInf_nodes['OthrNode']) 514 | PmtInf_nodes['Id_CdtrSchmeId_Node'].append( 515 | PmtInf_nodes['PrvtIdNode']) 516 | PmtInf_nodes['CdtrSchmeIdNode'].append( 517 | PmtInf_nodes['Id_CdtrSchmeId_Node']) 518 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['CdtrSchmeIdNode']) 519 | 520 | for txnode in batch_nodes: 521 | PmtInf_nodes['PmtInfNode'].append(txnode) 522 | 523 | CstmrDrctDbtInitn_node = self._xml.find('CstmrDrctDbtInitn') 524 | CstmrDrctDbtInitn_node.append(PmtInf_nodes['PmtInfNode']) 525 | -------------------------------------------------------------------------------- /sepaxml/schemas/pain.008.001.02.xsd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | 536 | 537 | 538 | 539 | 540 | 541 | 542 | 543 | 544 | 545 | 546 | 547 | 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | 556 | 557 | 558 | 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 574 | 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 | 583 | 584 | 585 | 586 | 587 | 588 | 589 | 590 | 591 | 592 | 593 | 594 | 595 | 596 | 597 | 598 | 599 | 600 | 601 | 602 | 603 | 604 | 605 | 606 | 607 | 608 | 609 | 610 | 611 | 612 | 613 | 614 | 615 | 616 | 617 | 618 | 619 | 620 | 621 | 622 | 623 | 624 | 625 | 626 | 627 | 628 | 629 | 630 | 631 | 632 | 633 | 634 | 635 | 636 | 637 | 638 | 639 | 640 | 641 | 642 | 643 | 644 | 645 | 646 | 647 | 648 | 649 | 650 | 651 | 652 | 653 | 654 | 655 | 656 | 657 | 658 | 659 | 660 | 661 | 662 | 663 | 664 | 665 | 666 | 667 | 668 | 669 | 670 | 671 | 672 | 673 | 674 | 675 | 676 | 677 | 678 | 679 | 680 | 681 | 682 | 683 | 684 | 685 | 686 | 687 | 688 | 689 | 690 | 691 | 692 | 693 | 694 | 695 | 696 | 697 | 698 | 699 | 700 | 701 | 702 | 703 | 704 | 705 | 706 | 707 | 708 | 709 | 710 | 711 | 712 | 713 | 714 | 715 | 716 | 717 | 718 | 719 | 720 | 721 | 722 | 723 | 724 | 725 | 726 | 727 | 728 | 729 | 730 | 731 | 732 | 733 | 734 | 735 | 736 | 737 | 738 | 739 | 740 | 741 | 742 | 743 | 744 | 745 | 746 | 747 | 748 | 749 | 750 | 751 | 752 | 753 | 754 | 755 | 756 | 757 | 758 | 759 | 760 | 761 | 762 | 763 | 764 | 765 | 766 | 767 | 768 | 769 | 770 | 771 | 772 | 773 | 774 | 775 | 776 | 777 | 778 | 779 | 780 | 781 | 782 | 783 | 784 | 785 | 786 | 787 | 788 | 789 | 790 | 791 | 792 | 793 | 794 | 795 | 796 | 797 | 798 | 799 | 800 | 801 | 802 | 803 | 804 | 805 | 806 | 807 | 808 | 809 | 810 | 811 | 812 | 813 | 814 | 815 | 816 | 817 | 818 | 819 | 820 | 821 | 822 | 823 | 824 | 825 | 826 | 827 | 828 | 829 | 830 | 831 | 832 | 833 | 834 | 835 | 836 | 837 | 838 | 839 | 840 | 841 | 842 | 843 | 844 | 845 | 846 | 847 | 848 | 849 | 850 | 851 | 852 | 853 | 854 | 855 | 856 | 857 | 858 | 859 | 860 | 861 | 862 | 863 | 864 | 865 | 866 | 867 | 868 | 869 | 870 | 871 | 872 | 873 | 874 | 875 | 876 | 877 | 878 | 879 | 880 | 881 | -------------------------------------------------------------------------------- /sepaxml/shared.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2014 Congressus, The Netherlands 3 | Copyright (c) 2017-2023 Raphael Michel and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is furnished 10 | to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 19 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | """ 22 | import xml.etree.ElementTree as ET 23 | from collections import OrderedDict 24 | 25 | from .utils import decimal_str_to_int, int_to_decimal_str, make_msg_id 26 | from .validation import try_valid_xml 27 | 28 | 29 | class SepaPaymentInitn: 30 | 31 | def __init__(self, config, schema, clean=True): 32 | """ 33 | Constructor. Checks the config, prepares the document and 34 | builds the header. 35 | @param param: The config dict. 36 | @raise exception: When the config file is invalid. 37 | """ 38 | self._config = None # Will contain the config file. 39 | self._xml = None # Will contain the final XML file. 40 | self._batches = OrderedDict() # Will contain the SEPA batches. 41 | self._batch_totals = OrderedDict() # Will contain the total amount to debit per batch for checksum total. 42 | self.schema = schema 43 | self.msg_id = make_msg_id() 44 | self.clean = clean 45 | 46 | config_result = self.check_config(config) 47 | if config_result: 48 | self._config = config 49 | if self.clean: 50 | from text_unidecode import unidecode 51 | 52 | self._config['name'] = unidecode(self._config['name'])[:70] 53 | 54 | self._prepare_document() 55 | self._create_header() 56 | 57 | def _prepare_document(self): 58 | """ 59 | Build the main document node and set xml namespaces. 60 | """ 61 | self._xml = ET.Element("Document") 62 | self._xml.set("xmlns", 63 | "urn:iso:std:iso:20022:tech:xsd:" + self.schema) 64 | self._xml.set("xmlns:xsi", 65 | "http://www.w3.org/2001/XMLSchema-instance") 66 | ET.register_namespace("", 67 | "urn:iso:std:iso:20022:tech:xsd:" + self.schema) 68 | ET.register_namespace("xsi", 69 | "http://www.w3.org/2001/XMLSchema-instance") 70 | n = ET.Element(self.root_el) 71 | self._xml.append(n) 72 | 73 | def _create_header(self): 74 | raise NotImplementedError() 75 | 76 | def _finalize_batch(self): 77 | raise NotImplementedError() 78 | 79 | def export(self, validate=True, pretty_print=False): 80 | """ 81 | Method to output the xml as string. It will finalize the batches and 82 | then calculate the checksums (amount sum and transaction count), 83 | fill these into the group header and output the XML. 84 | 85 | @param pretty_print: uses Python's xml.dom.minidom.Node.toprettyxml to make it easier to read for humans 86 | """ 87 | self._finalize_batch() 88 | 89 | ctrl_sum_total = 0 90 | nb_of_txs_total = 0 91 | 92 | for ctrl_sum in self._xml.iter('CtrlSum'): 93 | if ctrl_sum.text is None: 94 | continue 95 | ctrl_sum_total += decimal_str_to_int(ctrl_sum.text) 96 | 97 | for nb_of_txs in self._xml.iter('NbOfTxs'): 98 | if nb_of_txs.text is None: 99 | continue 100 | nb_of_txs_total += int(nb_of_txs.text) 101 | 102 | n = self._xml.find(self.root_el) 103 | GrpHdr_node = n.find('GrpHdr') 104 | CtrlSum_node = GrpHdr_node.find('CtrlSum') 105 | NbOfTxs_node = GrpHdr_node.find('NbOfTxs') 106 | CtrlSum_node.text = int_to_decimal_str(ctrl_sum_total) 107 | NbOfTxs_node.text = str(nb_of_txs_total) 108 | 109 | # Prepending the XML version is hacky, but cElementTree only offers this 110 | # automatically if you write to a file, which we don't necessarily want. 111 | out = b"" + ET.tostring( 112 | self._xml, "utf-8") 113 | 114 | if pretty_print: 115 | from xml.dom import minidom 116 | out_minidom = minidom.parseString(out) 117 | out = out_minidom.toprettyxml(encoding="utf-8") 118 | 119 | if validate: 120 | try_valid_xml(out, self.schema) 121 | return out 122 | -------------------------------------------------------------------------------- /sepaxml/transfer.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2017-2023 Raphael Michel and contributors 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is furnished 9 | to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 18 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | """ 21 | import datetime 22 | import xml.etree.ElementTree as ET 23 | 24 | from .shared import SepaPaymentInitn 25 | from .utils import ADDRESS_MAPPING, int_to_decimal_str, make_id 26 | 27 | 28 | class SepaTransfer(SepaPaymentInitn): 29 | """ 30 | This class creates a Sepa transfer XML File. 31 | """ 32 | root_el = "CstmrCdtTrfInitn" 33 | 34 | def __init__(self, config, schema="pain.001.001.03", clean=True): 35 | super().__init__(config, schema, clean) 36 | 37 | def check_config(self, config): 38 | """ 39 | Check the config file for required fields and validity. 40 | @param config: The config dict. 41 | @return: True if valid, error string if invalid paramaters where 42 | encountered. 43 | """ 44 | validation = "" 45 | required = ["name", "currency", "IBAN"] 46 | 47 | for config_item in required: 48 | if config_item not in config: 49 | validation += config_item.upper() + "_MISSING " 50 | 51 | if not validation: 52 | return True 53 | else: 54 | raise Exception("Config file did not validate. " + validation) 55 | 56 | def check_payment(self, payment): 57 | """ 58 | Check the payment for required fields and validity. 59 | @param payment: The payment dict 60 | @return: True if valid, error string if invalid paramaters where 61 | encountered. 62 | """ 63 | validation = "" 64 | required = ["name", "IBAN", "amount", "description", "execution_date"] 65 | 66 | for config_item in required: 67 | if config_item not in payment: 68 | validation += config_item.upper() + "_MISSING " 69 | 70 | if not isinstance(payment['amount'], int): 71 | validation += "AMOUNT_NOT_INTEGER " 72 | 73 | if 'execution_date' in payment: 74 | if not isinstance(payment['execution_date'], datetime.date): 75 | validation += "EXECUTION_DATE_INVALID_OR_NOT_DATETIME_INSTANCE" 76 | payment['execution_date'] = payment['execution_date'].isoformat() 77 | 78 | if validation == "": 79 | return True 80 | else: 81 | raise Exception('Payment did not validate: ' + validation) 82 | 83 | def add_payment(self, payment): 84 | """ 85 | Function to add payments 86 | @param payment: The payment dict 87 | @raise exception: when payment is invalid 88 | """ 89 | # Validate the payment 90 | self.check_payment(payment) 91 | 92 | if self.clean: 93 | from text_unidecode import unidecode 94 | 95 | payment['name'] = unidecode(payment['name'])[:70] 96 | payment['description'] = unidecode(payment['description'])[:140] 97 | 98 | # Get the CstmrDrctDbtInitnNode 99 | if not self._config['batch']: 100 | # Start building the non batch payment 101 | PmtInf_nodes = self._create_PmtInf_node() 102 | PmtInf_nodes['PmtInfIdNode'].text = make_id(self._config['name']) 103 | PmtInf_nodes['PmtMtdNode'].text = "TRF" 104 | PmtInf_nodes['BtchBookgNode'].text = "false" 105 | PmtInf_nodes['NbOfTxsNode'].text = "1" 106 | PmtInf_nodes['CtrlSumNode'].text = int_to_decimal_str( 107 | payment['amount'] 108 | ) 109 | if not self._config.get('domestic', False): 110 | PmtInf_nodes['Cd_SvcLvl_Node'].text = "SEPA" 111 | if 'execution_date' in payment: 112 | PmtInf_nodes['ReqdExctnDtNode'].text = payment['execution_date'] 113 | else: 114 | del PmtInf_nodes['ReqdExctnDtNode'] 115 | 116 | PmtInf_nodes['Nm_Dbtr_Node'].text = self._config['name'] 117 | if payment.get('address', {}): 118 | for d, n in ADDRESS_MAPPING: 119 | if self._config['address'].get(d): 120 | n = ET.Element(n) 121 | n.text = self._config['address'][d] 122 | PmtInf_nodes['PstlAdr_Dbtr_Node'].append(n) 123 | for line in self._config.get('lines', []): 124 | n = ET.Element('AdrLine') 125 | n.text = line 126 | PmtInf_nodes['PstlAdr_Dbtr_Node'].append(n) 127 | 128 | PmtInf_nodes['IBAN_DbtrAcct_Node'].text = self._config['IBAN'] 129 | if 'BIC' in self._config: 130 | PmtInf_nodes['BIC_DbtrAgt_Node'].text = self._config['BIC'] 131 | 132 | PmtInf_nodes['ChrgBrNode'].text = "SLEV" 133 | 134 | if 'BIC' in payment: 135 | bic = True 136 | else: 137 | bic = False 138 | 139 | TX_nodes = self._create_TX_node(bic) 140 | TX_nodes['InstdAmtNode'].set("Ccy", self._config['currency']) 141 | TX_nodes['InstdAmtNode'].text = int_to_decimal_str(payment['amount']) 142 | TX_nodes['EndToEnd_PmtId_Node'].text = payment.get('endtoend_id', 'NOTPROVIDED') 143 | if bic: 144 | TX_nodes['BIC_CdtrAgt_Node'].text = payment['BIC'] 145 | TX_nodes['Nm_Cdtr_Node'].text = payment['name'] 146 | if payment.get('address', {}): 147 | for d, n in ADDRESS_MAPPING: 148 | if payment['address'].get(d): 149 | n = ET.Element(n) 150 | n.text = payment['address'][d] 151 | TX_nodes['PstlAdr_Cdtr_Node'].append(n) 152 | for line in payment['address'].get('lines', []): 153 | n = ET.Element('AdrLine') 154 | n.text = line 155 | TX_nodes['PstlAdr_Cdtr_Node'].append(n) 156 | 157 | TX_nodes['IBAN_CdtrAcct_Node'].text = payment['IBAN'] 158 | TX_nodes['UstrdNode'].text = payment['description'] 159 | 160 | if self._config['batch']: 161 | self._add_batch(TX_nodes, payment) 162 | else: 163 | self._add_non_batch(TX_nodes, PmtInf_nodes) 164 | 165 | def _create_header(self): 166 | """ 167 | Function to create the GroupHeader (GrpHdr) in the 168 | CstmrCdtTrfInitn Node 169 | """ 170 | # Retrieve the node to which we will append the group header. 171 | CstmrCdtTrfInitn_node = self._xml.find('CstmrCdtTrfInitn') 172 | 173 | # Create the header nodes. 174 | GrpHdr_node = ET.Element("GrpHdr") 175 | MsgId_node = ET.Element("MsgId") 176 | CreDtTm_node = ET.Element("CreDtTm") 177 | NbOfTxs_node = ET.Element("NbOfTxs") 178 | CtrlSum_node = ET.Element("CtrlSum") 179 | InitgPty_node = ET.Element("InitgPty") 180 | Nm_node = ET.Element("Nm") 181 | 182 | # Add data to some header nodes. 183 | MsgId_node.text = self.msg_id 184 | CreDtTm_node.text = datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S') 185 | Nm_node.text = self._config['name'] 186 | 187 | # Append the nodes 188 | InitgPty_node.append(Nm_node) 189 | GrpHdr_node.append(MsgId_node) 190 | GrpHdr_node.append(CreDtTm_node) 191 | GrpHdr_node.append(NbOfTxs_node) 192 | GrpHdr_node.append(CtrlSum_node) 193 | GrpHdr_node.append(InitgPty_node) 194 | 195 | # Append the header to its parent 196 | CstmrCdtTrfInitn_node.append(GrpHdr_node) 197 | 198 | def _create_PmtInf_node(self): 199 | """ 200 | Method to create the blank payment information nodes as a dict. 201 | """ 202 | ED = dict() # ED is element dict 203 | ED['PmtInfNode'] = ET.Element("PmtInf") 204 | ED['PmtInfIdNode'] = ET.Element("PmtInfId") 205 | ED['PmtMtdNode'] = ET.Element("PmtMtd") 206 | ED['BtchBookgNode'] = ET.Element("BtchBookg") 207 | ED['NbOfTxsNode'] = ET.Element("NbOfTxs") 208 | ED['CtrlSumNode'] = ET.Element("CtrlSum") 209 | ED['PmtTpInfNode'] = ET.Element("PmtTpInf") 210 | if not self._config.get('domestic', False): 211 | ED['SvcLvlNode'] = ET.Element("SvcLvl") 212 | ED['Cd_SvcLvl_Node'] = ET.Element("Cd") 213 | ED['ReqdExctnDtNode'] = ET.Element("ReqdExctnDt") 214 | 215 | ED['DbtrNode'] = ET.Element("Dbtr") 216 | ED['Nm_Dbtr_Node'] = ET.Element("Nm") 217 | ED['PstlAdr_Dbtr_Node'] = ET.Element("PstlAdr") 218 | ED['DbtrAcctNode'] = ET.Element("DbtrAcct") 219 | ED['Id_DbtrAcct_Node'] = ET.Element("Id") 220 | ED['IBAN_DbtrAcct_Node'] = ET.Element("IBAN") 221 | ED['DbtrAgtNode'] = ET.Element("DbtrAgt") 222 | ED['FinInstnId_DbtrAgt_Node'] = ET.Element("FinInstnId") 223 | if 'BIC' in self._config: 224 | ED['BIC_DbtrAgt_Node'] = ET.Element("BIC") 225 | ED['ChrgBrNode'] = ET.Element("ChrgBr") 226 | return ED 227 | 228 | def _create_TX_node(self, bic=True): 229 | """ 230 | Method to create the blank transaction nodes as a dict. If bic is True, 231 | the BIC node will also be created. 232 | """ 233 | ED = dict() 234 | ED['CdtTrfTxInfNode'] = ET.Element("CdtTrfTxInf") 235 | ED['PmtIdNode'] = ET.Element("PmtId") 236 | ED['EndToEnd_PmtId_Node'] = ET.Element("EndToEndId") 237 | ED['AmtNode'] = ET.Element("Amt") 238 | ED['InstdAmtNode'] = ET.Element("InstdAmt") 239 | ED['CdtrNode'] = ET.Element("Cdtr") 240 | ED['Nm_Cdtr_Node'] = ET.Element("Nm") 241 | ED['PstlAdr_Cdtr_Node'] = ET.Element("PstlAdr") 242 | ED['CdtrAgtNode'] = ET.Element("CdtrAgt") 243 | ED['FinInstnId_CdtrAgt_Node'] = ET.Element("FinInstnId") 244 | if bic: 245 | ED['BIC_CdtrAgt_Node'] = ET.Element("BIC") 246 | ED['CdtrAcctNode'] = ET.Element("CdtrAcct") 247 | ED['Id_CdtrAcct_Node'] = ET.Element("Id") 248 | ED['IBAN_CdtrAcct_Node'] = ET.Element("IBAN") 249 | ED['RmtInfNode'] = ET.Element("RmtInf") 250 | ED['UstrdNode'] = ET.Element("Ustrd") 251 | return ED 252 | 253 | def _add_non_batch(self, TX_nodes, PmtInf_nodes): 254 | """ 255 | Method to add a transaction as non batch, will fold the transaction 256 | together with the payment info node and append to the main xml. 257 | """ 258 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['PmtInfIdNode']) 259 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['PmtMtdNode']) 260 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['BtchBookgNode']) 261 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['NbOfTxsNode']) 262 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['CtrlSumNode']) 263 | 264 | if not self._config.get('domestic', False): 265 | PmtInf_nodes['SvcLvlNode'].append(PmtInf_nodes['Cd_SvcLvl_Node']) 266 | PmtInf_nodes['PmtTpInfNode'].append(PmtInf_nodes['SvcLvlNode']) 267 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['PmtTpInfNode']) 268 | if 'ReqdExctnDtNode' in PmtInf_nodes: 269 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['ReqdExctnDtNode']) 270 | 271 | PmtInf_nodes['DbtrNode'].append(PmtInf_nodes['Nm_Dbtr_Node']) 272 | if "PstlAdr_Dbtr_Node" in TX_nodes: 273 | PmtInf_nodes['DbtrNode'].append(TX_nodes['PstlAdr_Dbtr_Node']) 274 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['DbtrNode']) 275 | 276 | PmtInf_nodes['Id_DbtrAcct_Node'].append(PmtInf_nodes['IBAN_DbtrAcct_Node']) 277 | PmtInf_nodes['DbtrAcctNode'].append(PmtInf_nodes['Id_DbtrAcct_Node']) 278 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['DbtrAcctNode']) 279 | 280 | if 'BIC' in self._config: 281 | PmtInf_nodes['FinInstnId_DbtrAgt_Node'].append(PmtInf_nodes['BIC_DbtrAgt_Node']) 282 | PmtInf_nodes['DbtrAgtNode'].append(PmtInf_nodes['FinInstnId_DbtrAgt_Node']) 283 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['DbtrAgtNode']) 284 | 285 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['ChrgBrNode']) 286 | 287 | TX_nodes['PmtIdNode'].append(TX_nodes['EndToEnd_PmtId_Node']) 288 | TX_nodes['AmtNode'].append(TX_nodes['InstdAmtNode']) 289 | TX_nodes['CdtTrfTxInfNode'].append(TX_nodes['PmtIdNode']) 290 | TX_nodes['CdtTrfTxInfNode'].append(TX_nodes['AmtNode']) 291 | 292 | if 'BIC_CdtrAgt_Node' in TX_nodes and TX_nodes['BIC_CdtrAgt_Node'].text is not None: 293 | TX_nodes['FinInstnId_CdtrAgt_Node'].append( 294 | TX_nodes['BIC_CdtrAgt_Node']) 295 | TX_nodes['CdtrAgtNode'].append(TX_nodes['FinInstnId_CdtrAgt_Node']) 296 | TX_nodes['CdtTrfTxInfNode'].append(TX_nodes['CdtrAgtNode']) 297 | 298 | TX_nodes['CdtrNode'].append(TX_nodes['Nm_Cdtr_Node']) 299 | if TX_nodes['PstlAdr_Cdtr_Node']: 300 | TX_nodes['CdtrNode'].append(TX_nodes['PstlAdr_Cdtr_Node']) 301 | 302 | TX_nodes['CdtTrfTxInfNode'].append(TX_nodes['CdtrNode']) 303 | 304 | TX_nodes['Id_CdtrAcct_Node'].append(TX_nodes['IBAN_CdtrAcct_Node']) 305 | TX_nodes['CdtrAcctNode'].append(TX_nodes['Id_CdtrAcct_Node']) 306 | TX_nodes['CdtTrfTxInfNode'].append(TX_nodes['CdtrAcctNode']) 307 | 308 | TX_nodes['RmtInfNode'].append(TX_nodes['UstrdNode']) 309 | TX_nodes['CdtTrfTxInfNode'].append(TX_nodes['RmtInfNode']) 310 | PmtInf_nodes['PmtInfNode'].append(TX_nodes['CdtTrfTxInfNode']) 311 | CstmrCdtTrfInitn_node = self._xml.find('CstmrCdtTrfInitn') 312 | CstmrCdtTrfInitn_node.append(PmtInf_nodes['PmtInfNode']) 313 | 314 | def _add_batch(self, TX_nodes, payment): 315 | """ 316 | Method to add a payment as a batch. The transaction details are already 317 | present. Will fold the nodes accordingly and the call the 318 | _add_to_batch_list function to store the batch. 319 | """ 320 | TX_nodes['PmtIdNode'].append(TX_nodes['EndToEnd_PmtId_Node']) 321 | TX_nodes['AmtNode'].append(TX_nodes['InstdAmtNode']) 322 | TX_nodes['CdtTrfTxInfNode'].append(TX_nodes['PmtIdNode']) 323 | TX_nodes['CdtTrfTxInfNode'].append(TX_nodes['AmtNode']) 324 | 325 | if 'BIC_CdtrAgt_Node' in TX_nodes and TX_nodes['BIC_CdtrAgt_Node'].text is not None: 326 | TX_nodes['FinInstnId_CdtrAgt_Node'].append( 327 | TX_nodes['BIC_CdtrAgt_Node']) 328 | TX_nodes['CdtrAgtNode'].append(TX_nodes['FinInstnId_CdtrAgt_Node']) 329 | TX_nodes['CdtTrfTxInfNode'].append(TX_nodes['CdtrAgtNode']) 330 | 331 | TX_nodes['CdtrNode'].append(TX_nodes['Nm_Cdtr_Node']) 332 | if TX_nodes['PstlAdr_Cdtr_Node']: 333 | TX_nodes['CdtrNode'].append(TX_nodes['PstlAdr_Cdtr_Node']) 334 | TX_nodes['CdtTrfTxInfNode'].append(TX_nodes['CdtrNode']) 335 | 336 | TX_nodes['Id_CdtrAcct_Node'].append(TX_nodes['IBAN_CdtrAcct_Node']) 337 | TX_nodes['CdtrAcctNode'].append(TX_nodes['Id_CdtrAcct_Node']) 338 | TX_nodes['CdtTrfTxInfNode'].append(TX_nodes['CdtrAcctNode']) 339 | 340 | TX_nodes['RmtInfNode'].append(TX_nodes['UstrdNode']) 341 | TX_nodes['CdtTrfTxInfNode'].append(TX_nodes['RmtInfNode']) 342 | self._add_to_batch_list(TX_nodes, payment) 343 | 344 | def _add_to_batch_list(self, TX, payment): 345 | """ 346 | Method to add a transaction to the batch list. The correct batch will 347 | be determined by the payment dict and the batch will be created if 348 | not existant. This will also add the payment amount to the respective 349 | batch total. 350 | """ 351 | batch_key = payment.get('execution_date', None) 352 | if batch_key in self._batches.keys(): 353 | self._batches[batch_key].append(TX['CdtTrfTxInfNode']) 354 | else: 355 | self._batches[batch_key] = [] 356 | self._batches[batch_key].append(TX['CdtTrfTxInfNode']) 357 | 358 | if batch_key in self._batch_totals: 359 | self._batch_totals[batch_key] += payment['amount'] 360 | else: 361 | self._batch_totals[batch_key] = payment['amount'] 362 | 363 | def _finalize_batch(self): 364 | """ 365 | Method to finalize the batch, this will iterate over the _batches dict 366 | and create a PmtInf node for each batch. The correct information (from 367 | the batch_key and batch_totals) will be inserted and the batch 368 | transaction nodes will be folded. Finally, the batches will be added to 369 | the main XML. 370 | """ 371 | for batch_meta, batch_nodes in self._batches.items(): 372 | PmtInf_nodes = self._create_PmtInf_node() 373 | PmtInf_nodes['PmtInfIdNode'].text = make_id(self._config['name']) 374 | PmtInf_nodes['PmtMtdNode'].text = "TRF" 375 | PmtInf_nodes['BtchBookgNode'].text = "true" 376 | if not self._config.get('domestic', False): 377 | PmtInf_nodes['Cd_SvcLvl_Node'].text = "SEPA" 378 | 379 | if batch_meta: 380 | PmtInf_nodes['ReqdExctnDtNode'].text = batch_meta 381 | else: 382 | del PmtInf_nodes['ReqdExctnDtNode'] 383 | PmtInf_nodes['Nm_Dbtr_Node'].text = self._config['name'] 384 | if self._config.get('address', {}): 385 | for d, n in ADDRESS_MAPPING: 386 | if self._config['address'].get(d): 387 | n = ET.Element(n) 388 | n.text = self._config['address'][d] 389 | PmtInf_nodes['PstlAdr_Dbtr_Node'].append(n) 390 | for line in self._config['address'].get('lines', []): 391 | n = ET.Element('AdrLine') 392 | n.text = line 393 | PmtInf_nodes['PstlAdr_Dbtr_Node'].append(n) 394 | PmtInf_nodes['IBAN_DbtrAcct_Node'].text = self._config['IBAN'] 395 | 396 | if 'BIC' in self._config: 397 | PmtInf_nodes['BIC_DbtrAgt_Node'].text = self._config['BIC'] 398 | 399 | PmtInf_nodes['ChrgBrNode'].text = "SLEV" 400 | 401 | PmtInf_nodes['NbOfTxsNode'].text = str(len(batch_nodes)) 402 | PmtInf_nodes['CtrlSumNode'].text = int_to_decimal_str(self._batch_totals[batch_meta]) 403 | 404 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['PmtInfIdNode']) 405 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['PmtMtdNode']) 406 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['BtchBookgNode']) 407 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['NbOfTxsNode']) 408 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['CtrlSumNode']) 409 | 410 | if not self._config.get('domestic', False): 411 | PmtInf_nodes['SvcLvlNode'].append(PmtInf_nodes['Cd_SvcLvl_Node']) 412 | PmtInf_nodes['PmtTpInfNode'].append(PmtInf_nodes['SvcLvlNode']) 413 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['PmtTpInfNode']) 414 | if 'ReqdExctnDtNode' in PmtInf_nodes: 415 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['ReqdExctnDtNode']) 416 | 417 | PmtInf_nodes['DbtrNode'].append(PmtInf_nodes['Nm_Dbtr_Node']) 418 | if PmtInf_nodes['PstlAdr_Dbtr_Node']: 419 | PmtInf_nodes['DbtrNode'].append(PmtInf_nodes['PstlAdr_Dbtr_Node']) 420 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['DbtrNode']) 421 | 422 | PmtInf_nodes['Id_DbtrAcct_Node'].append(PmtInf_nodes['IBAN_DbtrAcct_Node']) 423 | PmtInf_nodes['DbtrAcctNode'].append(PmtInf_nodes['Id_DbtrAcct_Node']) 424 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['DbtrAcctNode']) 425 | 426 | if 'BIC' in self._config: 427 | PmtInf_nodes['FinInstnId_DbtrAgt_Node'].append(PmtInf_nodes['BIC_DbtrAgt_Node']) 428 | PmtInf_nodes['DbtrAgtNode'].append(PmtInf_nodes['FinInstnId_DbtrAgt_Node']) 429 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['DbtrAgtNode']) 430 | 431 | PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['ChrgBrNode']) 432 | 433 | for txnode in batch_nodes: 434 | PmtInf_nodes['PmtInfNode'].append(txnode) 435 | 436 | CstmrCdtTrfInitn_node = self._xml.find('CstmrCdtTrfInitn') 437 | CstmrCdtTrfInitn_node.append(PmtInf_nodes['PmtInfNode']) 438 | -------------------------------------------------------------------------------- /sepaxml/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2014 Congressus, The Netherlands 3 | Copyright (c) 2017-2023 Raphael Michel and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is furnished 10 | to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 19 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | """ 22 | import datetime 23 | import hashlib 24 | import random 25 | import re 26 | import time 27 | 28 | try: 29 | random = random.SystemRandom() 30 | using_sysrandom = True 31 | except NotImplementedError: 32 | import warnings 33 | warnings.warn('A secure pseudo-random number generator is not available ' 34 | 'on your system. Falling back to Mersenne Twister.') 35 | using_sysrandom = False 36 | 37 | 38 | def get_rand_string(length=12, allowed_chars='0123456789abcdef'): 39 | """ 40 | Returns a securely generated random string. Taken from the Django project 41 | 42 | The default length of 12 with the a-z, A-Z, 0-9 character set returns 43 | a 71-bit value. log_2((26+26+10)^12) =~ 71 bits 44 | """ 45 | if not using_sysrandom: 46 | # This is ugly, and a hack, but it makes things better than 47 | # the alternative of predictability. This re-seeds the PRNG 48 | # using a value that is hard for an attacker to predict, every 49 | # time a random string is required. This may change the 50 | # properties of the chosen random sequence slightly, but this 51 | # is better than absolute predictability. 52 | random.seed( 53 | hashlib.sha256( 54 | ("%s%s" % ( 55 | random.getstate(), 56 | time.time())).encode('utf-8') 57 | ).digest()) 58 | return ''.join([random.choice(allowed_chars) for i in range(length)]) 59 | 60 | 61 | def make_msg_id(): 62 | """ 63 | Create a semi random message id, by using 12 char random hex string and 64 | a timestamp. 65 | @return: string consisting of timestamp, -, random value 66 | """ 67 | random_string = get_rand_string(12) 68 | timestamp = datetime.datetime.now().strftime("%Y%m%d%I%M%S") 69 | msg_id = timestamp + "-" + random_string 70 | return msg_id 71 | 72 | 73 | def make_id(name): 74 | """ 75 | Create a random id combined with the creditor name. 76 | @return string consisting of name (truncated at 22 chars), -, 77 | 12 char rand hex string. 78 | """ 79 | name = re.sub(r'[^a-zA-Z0-9]', '', name) 80 | r = get_rand_string(12) 81 | if len(name) > 22: 82 | name = name[:22] 83 | return name + "-" + r 84 | 85 | 86 | def int_to_decimal_str(integer): 87 | """ 88 | Helper to convert integers (representing cents) into decimal currency 89 | string. WARNING: DO NOT TRY TO DO THIS BY DIVISION, FLOATING POINT 90 | ERRORS ARE NO FUN IN FINANCIAL SYSTEMS. 91 | @param integer The amount in cents 92 | @return string The amount in currency with full stop decimal separator 93 | """ 94 | int_string = str(integer) 95 | if len(int_string) <= 2: 96 | return "0." + int_string.zfill(2) 97 | else: 98 | return int_string[:-2] + "." + int_string[-2:] 99 | 100 | 101 | def decimal_str_to_int(decimal_string): 102 | """ 103 | Helper to decimal currency string into integers (cents). 104 | WARNING: DO NOT TRY TO DO THIS BY CONVERSION AND MULTIPLICATION, 105 | FLOATING POINT ERRORS ARE NO FUN IN FINANCIAL SYSTEMS. 106 | @param string The amount in currency with full stop decimal separator 107 | @return integer The amount in cents 108 | """ 109 | int_string = decimal_string.replace('.', '') 110 | int_string = int_string.lstrip('0') 111 | return int(int_string) 112 | 113 | 114 | ADDRESS_MAPPING = ( 115 | ("address_type", "AdrTp"), 116 | ("department", "Dept"), 117 | ("subdepartment", "SubDept"), 118 | ("street_name", "StrtNm"), 119 | ("building_number", "BldgNb"), 120 | ("postcode", "PstCd"), 121 | ("town", "TwnNm"), 122 | ("country_subdivision", "CtrySubDvsn"), 123 | ("country", "Ctry"), 124 | ) 125 | -------------------------------------------------------------------------------- /sepaxml/validation.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2017-2023 Raphael Michel and contributors 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is furnished 9 | to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 18 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | """ 21 | import os 22 | 23 | 24 | class ValidationError(Exception): 25 | pass 26 | 27 | 28 | def try_valid_xml(xmlout, schema): 29 | import xmlschema # xmlschema does some weird monkeypatching in etree, if we import it globally, things fail 30 | try: 31 | my_schema = xmlschema.XMLSchema(os.path.join(os.path.dirname(__file__), 'schemas', schema + '.xsd')) 32 | my_schema.validate(xmlout.decode()) 33 | 34 | except xmlschema.XMLSchemaValidationError as e: 35 | raise ValidationError( 36 | "The output SEPA file contains validation errors. This is likely due to an illegal value in one of " 37 | "your input fields." 38 | ) from e 39 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 160 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from codecs import open 2 | from os import path 3 | 4 | from setuptools import find_packages, setup 5 | 6 | from sepaxml import version 7 | 8 | here = path.abspath(path.dirname(__file__)) 9 | 10 | try: 11 | # Get the long description from the relevant file 12 | with open(path.join(here, 'README.rst'), encoding='utf-8') as f: 13 | long_description = f.read() 14 | except: 15 | long_description = '' 16 | 17 | setup( 18 | name='sepaxml', 19 | version=version, 20 | description='Python SEPA XML implementations', 21 | long_description=long_description, 22 | url='https://github.com/raphaelm/python-sepaxml', 23 | author='Raphael Michel', 24 | author_email='mail@raphaelmichel.de', 25 | license='MIT License', 26 | classifiers=[ 27 | 'Development Status :: 4 - Beta', 28 | 'Intended Audience :: Developers', 29 | 'Intended Audience :: Other Audience', 30 | 'License :: OSI Approved :: MIT License', 31 | 'Programming Language :: Python :: 3.4', 32 | 'Programming Language :: Python :: 3.5', 33 | 'Programming Language :: Python :: 3.6', 34 | ], 35 | 36 | keywords='xml banking sepa', 37 | install_requires=[ 38 | 'xmlschema', 39 | 'text-unidecode' 40 | ], 41 | 42 | include_package_data=True, 43 | packages=find_packages(include=['sepaxml', 'sepaxml.*', 'sepadd', 'sepadd.*']), 44 | ) 45 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raphaelm/python-sepaxml/09063d3d61b6a1c392d46a1312ad02ef069175f2/tests/__init__.py -------------------------------------------------------------------------------- /tests/debit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raphaelm/python-sepaxml/09063d3d61b6a1c392d46a1312ad02ef069175f2/tests/debit/__init__.py -------------------------------------------------------------------------------- /tests/debit/test_00800102.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | from sepaxml import SepaDD 6 | from sepaxml.validation import ValidationError 7 | from tests.utils import clean_ids, validate_xml 8 | 9 | 10 | @pytest.fixture 11 | def sdd(): 12 | return SepaDD({ 13 | "name": "TestCreditor", 14 | "IBAN": "NL50BANK1234567890", 15 | "BIC": "BANKNL2A", 16 | "batch": True, 17 | "creditor_id": "DE26ZZZ00000000000", 18 | "currency": "EUR" 19 | }, schema="pain.008.001.02") 20 | 21 | 22 | SAMPLE_RESULT = b""" 23 | 24 | 25 | 26 | 20012017014921-ba2dab283fdd 27 | 2017-01-20T13:49:21 28 | 2 29 | 60.12 30 | 31 | TestCreditor 32 | 33 | 34 | 35 | DE26ZZZ00000000000 36 | 37 | 38 | 39 | 40 | 41 | 42 | TestCreditor-ecd6a2f680ce 43 | DD 44 | true 45 | 1 46 | 10.12 47 | 48 | 49 | SEPA 50 | 51 | 52 | CORE 53 | 54 | FRST 55 | 56 | 2017-01-20 57 | 58 | TestCreditor 59 | 60 | 61 | 62 | NL50BANK1234567890 63 | 64 | 65 | 66 | 67 | BANKNL2A 68 | 69 | 70 | SLEV 71 | 72 | 73 | 74 | 75 | DE26ZZZ00000000000 76 | 77 | SEPA 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | TestCreditor-4431989789fb 86 | 87 | 10.12 88 | 89 | 90 | 1234 91 | 2017-01-20 92 | 93 | 94 | 95 | 96 | BANKNL2A 97 | 98 | 99 | 100 | Test von Testenstein 101 | 102 | 103 | 104 | NL50BANK1234567890 105 | 106 | 107 | 108 | Test transaction1 109 | 110 | 111 | 112 | 113 | TestCreditor-d547a1b3882f 114 | DD 115 | true 116 | 1 117 | 50.00 118 | 119 | 120 | SEPA 121 | 122 | 123 | CORE 124 | 125 | RCUR 126 | 127 | 2017-01-20 128 | 129 | TestCreditor 130 | 131 | 132 | 133 | NL50BANK1234567890 134 | 135 | 136 | 137 | 138 | BANKNL2A 139 | 140 | 141 | SLEV 142 | 143 | 144 | 145 | 146 | DE26ZZZ00000000000 147 | 148 | SEPA 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | TestCreditor-7e989083e265 157 | 158 | 50.00 159 | 160 | 161 | 1234 162 | 2017-01-20 163 | 164 | 165 | 166 | 167 | BANKNL2A 168 | 169 | 170 | 171 | Test du Test 172 | 173 | 174 | 175 | NL50BANK1234567890 176 | 177 | 178 | 179 | Test transaction2 180 | 181 | 182 | 183 | 184 | 185 | """ 186 | 187 | 188 | def test_two_debits(sdd): 189 | payment1 = { 190 | "name": "Test von Testenstein", 191 | "IBAN": "NL50BANK1234567890", 192 | "BIC": "BANKNL2A", 193 | "amount": 1012, 194 | "type": "FRST", 195 | "collection_date": datetime.date.today(), 196 | "mandate_id": "1234", 197 | "mandate_date": datetime.date.today(), 198 | "description": "Test transaction1" 199 | } 200 | payment2 = { 201 | "name": "Test du Test", 202 | "IBAN": "NL50BANK1234567890", 203 | "BIC": "BANKNL2A", 204 | "amount": 5000, 205 | "type": "RCUR", 206 | "collection_date": datetime.date.today(), 207 | "mandate_id": "1234", 208 | "mandate_date": datetime.date.today(), 209 | "description": "Test transaction2" 210 | } 211 | 212 | sdd.add_payment(payment1) 213 | sdd.add_payment(payment2) 214 | xmlout = sdd.export() 215 | xmlpretty = validate_xml(xmlout, "pain.008.001.02") 216 | assert clean_ids(xmlpretty.strip()) == clean_ids(SAMPLE_RESULT.strip()) 217 | -------------------------------------------------------------------------------- /tests/debit/test_config.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from sepaxml import SepaDD 4 | 5 | 6 | def test_valid_config(): 7 | SepaDD({ 8 | "name": "TestCreditor", 9 | "IBAN": "NL50BANK1234567890", 10 | "BIC": "BANKNL2A", 11 | "batch": True, 12 | "creditor_id": "000000", 13 | "currency": "EUR" 14 | }) 15 | 16 | 17 | def test_invalid_config(): 18 | with pytest.raises(Exception): 19 | SepaDD({ 20 | "name": "TestCreditor", 21 | "BIC": "BANKNL2A", 22 | "batch": True, 23 | "creditor_id": "000000", 24 | "currency": "EUR" 25 | }) 26 | -------------------------------------------------------------------------------- /tests/debit/test_endtoendid.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | import datetime 4 | 5 | import pytest 6 | 7 | from sepaxml import SepaDD 8 | from tests.utils import clean_ids, validate_xml 9 | 10 | 11 | @pytest.fixture 12 | def sdd(): 13 | return SepaDD({ 14 | "name": "Miller & Son Ltd", 15 | "IBAN": "NL50BANK1234567890", 16 | "BIC": "BANKNL2A", 17 | "batch": True, 18 | "creditor_id": "DE26ZZZ00000000000", 19 | "currency": "EUR" 20 | }, schema="pain.008.001.02") 21 | 22 | 23 | SAMPLE_RESULT = b""" 24 | 25 | 26 | 27 | 20012017014921-ba2dab283fdd 28 | 2017-01-20T13:49:21 29 | 2 30 | 60.12 31 | 32 | Miller & Son Ltd 33 | 34 | 35 | 36 | DE26ZZZ00000000000 37 | 38 | 39 | 40 | 41 | 42 | 43 | MillerSonLtd-ecd6a2f680ce 44 | DD 45 | true 46 | 1 47 | 10.12 48 | 49 | 50 | SEPA 51 | 52 | 53 | CORE 54 | 55 | FRST 56 | 57 | 2017-01-20 58 | 59 | Miller & Son Ltd 60 | 61 | 62 | 63 | NL50BANK1234567890 64 | 65 | 66 | 67 | 68 | BANKNL2A 69 | 70 | 71 | SLEV 72 | 73 | 74 | 75 | 76 | DE26ZZZ00000000000 77 | 78 | SEPA 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | ebd75e7e649375d91b33dc11ae44c0e1 87 | 88 | 10.12 89 | 90 | 91 | 1234 92 | 2017-01-20 93 | 94 | 95 | 96 | 97 | BANKNL2A 98 | 99 | 100 | 101 | Test & Co. 102 | 103 | 104 | 105 | NL50BANK1234567890 106 | 107 | 108 | 109 | Test transaction1 110 | 111 | 112 | 113 | 114 | MillerSonLtd-d547a1b3882f 115 | DD 116 | true 117 | 1 118 | 50.00 119 | 120 | 121 | SEPA 122 | 123 | 124 | CORE 125 | 126 | RCUR 127 | 128 | 2017-01-20 129 | 130 | Miller & Son Ltd 131 | 132 | 133 | 134 | NL50BANK1234567890 135 | 136 | 137 | 138 | 139 | BANKNL2A 140 | 141 | 142 | SLEV 143 | 144 | 145 | 146 | 147 | DE26ZZZ00000000000 148 | 149 | SEPA 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | af755a40cb692551ed9f9d55f7179525 158 | 159 | 50.00 160 | 161 | 162 | 1234 163 | 2017-01-20 164 | 165 | 166 | 167 | 168 | BANKNL2A 169 | 170 | 171 | 172 | Test du Test 173 | 174 | 175 | 176 | NL50BANK1234567890 177 | 178 | 179 | 180 | Testgrusse <html> 181 | 182 | 183 | 184 | 185 | 186 | """ 187 | 188 | 189 | def test_two_debits(sdd): 190 | payment1 = { 191 | "name": "Test & Co.", 192 | "IBAN": "NL50BANK1234567890", 193 | "BIC": "BANKNL2A", 194 | "amount": 1012, 195 | "type": "FRST", 196 | "collection_date": datetime.date.today(), 197 | "mandate_id": "1234", 198 | "mandate_date": datetime.date.today(), 199 | "description": "Test transaction1", 200 | "endtoend_id": "ebd75e7e649375d91b33dc11ae44c0e1" 201 | } 202 | payment2 = { 203 | "name": "Test du Test", 204 | "IBAN": "NL50BANK1234567890", 205 | "BIC": "BANKNL2A", 206 | "amount": 5000, 207 | "type": "RCUR", 208 | "collection_date": datetime.date.today(), 209 | "mandate_id": "1234", 210 | "mandate_date": datetime.date.today(), 211 | "description": u"Testgrüße ", 212 | "endtoend_id": "af755a40cb692551ed9f9d55f7179525" 213 | } 214 | 215 | sdd.add_payment(payment1) 216 | sdd.add_payment(payment2) 217 | xmlout = sdd.export() 218 | xmlpretty = validate_xml(xmlout, "pain.008.001.02") 219 | assert clean_ids(xmlpretty.strip()) == clean_ids(SAMPLE_RESULT.strip()) 220 | -------------------------------------------------------------------------------- /tests/debit/test_escaped.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | from sepaxml import SepaDD 6 | from tests.utils import clean_ids, validate_xml 7 | 8 | 9 | @pytest.fixture 10 | def sdd(): 11 | return SepaDD({ 12 | "name": "Müller & Sohn Ltd", 13 | "IBAN": "NL50BANK1234567890", 14 | "BIC": "BANKNL2A", 15 | "batch": True, 16 | "creditor_id": "DE26ZZZ00000000000", 17 | "currency": "EUR" 18 | }, schema="pain.008.001.02") 19 | 20 | 21 | SAMPLE_RESULT = b""" 22 | 23 | 24 | 25 | 20012017014921-ba2dab283fdd 26 | 2017-01-20T13:49:21 27 | 2 28 | 60.12 29 | 30 | Muller & Sohn Ltd 31 | 32 | 33 | 34 | DE26ZZZ00000000000 35 | 36 | 37 | 38 | 39 | 40 | 41 | MullerSohnLtd-ecd6a2f680ce 42 | DD 43 | true 44 | 1 45 | 10.12 46 | 47 | 48 | SEPA 49 | 50 | 51 | CORE 52 | 53 | FRST 54 | 55 | 2017-01-20 56 | 57 | Muller & Sohn Ltd 58 | 59 | 60 | 61 | NL50BANK1234567890 62 | 63 | 64 | 65 | 66 | BANKNL2A 67 | 68 | 69 | SLEV 70 | 71 | 72 | 73 | 74 | DE26ZZZ00000000000 75 | 76 | SEPA 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | MullerSohnLtd-4431989789fb 85 | 86 | 10.12 87 | 88 | 89 | 1234 90 | 2017-01-20 91 | 92 | 93 | 94 | 95 | BANKNL2A 96 | 97 | 98 | 99 | Test & Co. 100 | 101 | 102 | 103 | NL50BANK1234567890 104 | 105 | 106 | 107 | Test transaction1 108 | 109 | 110 | 111 | 112 | MullerSohnLtd-d547a1b3882f 113 | DD 114 | true 115 | 1 116 | 50.00 117 | 118 | 119 | SEPA 120 | 121 | 122 | CORE 123 | 124 | RCUR 125 | 126 | 2017-01-20 127 | 128 | Muller & Sohn Ltd 129 | 130 | 131 | 132 | NL50BANK1234567890 133 | 134 | 135 | 136 | 137 | BANKNL2A 138 | 139 | 140 | SLEV 141 | 142 | 143 | 144 | 145 | DE26ZZZ00000000000 146 | 147 | SEPA 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | MullerSohnLtd-7e989083e265 156 | 157 | 50.00 158 | 159 | 160 | 1234 161 | 2017-01-20 162 | 163 | 164 | 165 | 166 | BANKNL2A 167 | 168 | 169 | 170 | Test du Test 171 | 172 | 173 | 174 | NL50BANK1234567890 175 | 176 | 177 | 178 | Testgrusse <html> 179 | 180 | 181 | 182 | 183 | 184 | """ 185 | 186 | 187 | def test_two_debits(sdd): 188 | payment1 = { 189 | "name": "Test & Co.", 190 | "IBAN": "NL50BANK1234567890", 191 | "BIC": "BANKNL2A", 192 | "amount": 1012, 193 | "type": "FRST", 194 | "collection_date": datetime.date.today(), 195 | "mandate_id": "1234", 196 | "mandate_date": datetime.date.today(), 197 | "description": "Test transaction1" 198 | } 199 | payment2 = { 200 | "name": "Test dü Test", 201 | "IBAN": "NL50BANK1234567890", 202 | "BIC": "BANKNL2A", 203 | "amount": 5000, 204 | "type": "RCUR", 205 | "collection_date": datetime.date.today(), 206 | "mandate_id": "1234", 207 | "mandate_date": datetime.date.today(), 208 | "description": "Testgrüße " 209 | } 210 | 211 | sdd.add_payment(payment1) 212 | sdd.add_payment(payment2) 213 | xmlout = sdd.export() 214 | xmlpretty = validate_xml(xmlout, "pain.008.001.02") 215 | assert clean_ids(xmlpretty.strip()) == clean_ids(SAMPLE_RESULT.strip()) 216 | -------------------------------------------------------------------------------- /tests/debit/test_no_bic.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | from sepadd import SepaDD 6 | 7 | from ..utils import clean_ids, validate_xml 8 | 9 | 10 | @pytest.fixture 11 | def sdd(): 12 | return SepaDD({ 13 | "name": "TestCreditor", 14 | "IBAN": "NL50BANK1234567890", 15 | "BIC": "BANKNL2A", 16 | "batch": True, 17 | "creditor_id": "DE26ZZZ00000000000", 18 | "currency": "EUR" 19 | }, schema="pain.008.001.02") 20 | 21 | 22 | SAMPLE_RESULT = b""" 23 | 24 | 25 | 26 | 20012017014921-ba2dab283fdd 27 | 2017-01-20T13:49:21 28 | 2 29 | 60.12 30 | 31 | TestCreditor 32 | 33 | 34 | 35 | DE26ZZZ00000000000 36 | 37 | 38 | 39 | 40 | 41 | 42 | TestCreditor-ecd6a2f680ce 43 | DD 44 | true 45 | 1 46 | 10.12 47 | 48 | 49 | SEPA 50 | 51 | 52 | CORE 53 | 54 | FRST 55 | 56 | 2017-01-20 57 | 58 | TestCreditor 59 | 60 | 61 | 62 | NL50BANK1234567890 63 | 64 | 65 | 66 | 67 | BANKNL2A 68 | 69 | 70 | SLEV 71 | 72 | 73 | 74 | 75 | DE26ZZZ00000000000 76 | 77 | SEPA 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | TestCreditor-4431989789fb 86 | 87 | 10.12 88 | 89 | 90 | 1234 91 | 2017-01-20 92 | 93 | 94 | 95 | 96 | 97 | NOTPROVIDED 98 | 99 | 100 | 101 | 102 | Test von Testenstein 103 | 104 | 105 | 106 | NL50BANK1234567890 107 | 108 | 109 | 110 | Test transaction1 111 | 112 | 113 | 114 | 115 | TestCreditor-d547a1b3882f 116 | DD 117 | true 118 | 1 119 | 50.00 120 | 121 | 122 | SEPA 123 | 124 | 125 | CORE 126 | 127 | RCUR 128 | 129 | 2017-01-20 130 | 131 | TestCreditor 132 | 133 | 134 | 135 | NL50BANK1234567890 136 | 137 | 138 | 139 | 140 | BANKNL2A 141 | 142 | 143 | SLEV 144 | 145 | 146 | 147 | 148 | DE26ZZZ00000000000 149 | 150 | SEPA 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | TestCreditor-7e989083e265 159 | 160 | 50.00 161 | 162 | 163 | 1234 164 | 2017-01-20 165 | 166 | 167 | 168 | 169 | 170 | NOTPROVIDED 171 | 172 | 173 | 174 | 175 | Test du Test 176 | 177 | 178 | 179 | NL50BANK1234567890 180 | 181 | 182 | 183 | Test transaction2 184 | 185 | 186 | 187 | 188 | 189 | """ 190 | 191 | 192 | def test_two_debits(sdd): 193 | payment1 = { 194 | "name": "Test von Testenstein", 195 | "IBAN": "NL50BANK1234567890", 196 | "amount": 1012, 197 | "type": "FRST", 198 | "collection_date": datetime.date.today(), 199 | "mandate_id": "1234", 200 | "mandate_date": datetime.date.today(), 201 | "description": "Test transaction1" 202 | } 203 | payment2 = { 204 | "name": "Test du Test", 205 | "IBAN": "NL50BANK1234567890", 206 | "amount": 5000, 207 | "type": "RCUR", 208 | "collection_date": datetime.date.today(), 209 | "mandate_id": "1234", 210 | "mandate_date": datetime.date.today(), 211 | "description": "Test transaction2" 212 | } 213 | 214 | sdd.add_payment(payment1) 215 | sdd.add_payment(payment2) 216 | xmlout = sdd.export() 217 | xmlpretty = validate_xml(xmlout, "pain.008.001.02") 218 | assert clean_ids(xmlpretty.strip()) == clean_ids(SAMPLE_RESULT.strip()) 219 | -------------------------------------------------------------------------------- /tests/debit/test_non_batch.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | from sepaxml import SepaDD 6 | from tests.utils import clean_ids, validate_xml 7 | 8 | 9 | @pytest.fixture 10 | def sdd(): 11 | return SepaDD({ 12 | "name": "TestCreditor", 13 | "IBAN": "NL50BANK1234567890", 14 | "BIC": "BANKNL2A", 15 | "batch": False, 16 | "creditor_id": "DE26ZZZ00000000000", 17 | "currency": "EUR" 18 | }, schema="pain.008.001.02") 19 | 20 | 21 | SAMPLE_RESULT = b""" 22 | 23 | 24 | 25 | 20012017014921-ba2dab283fdd 26 | 2017-01-20T13:49:21 27 | 2 28 | 60.12 29 | 30 | TestCreditor 31 | 32 | 33 | 34 | DE26ZZZ00000000000 35 | 36 | 37 | 38 | 39 | 40 | 41 | TestCreditor-ecd6a2f680ce 42 | DD 43 | false 44 | 1 45 | 10.12 46 | 47 | 48 | SEPA 49 | 50 | 51 | CORE 52 | 53 | FRST 54 | 55 | 2017-01-20 56 | 57 | TestCreditor 58 | 59 | 60 | 61 | NL50BANK1234567890 62 | 63 | 64 | 65 | 66 | BANKNL2A 67 | 68 | 69 | SLEV 70 | 71 | 72 | 73 | 74 | DE26ZZZ00000000000 75 | 76 | SEPA 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | TestCreditor-4431989789fb 85 | 86 | 10.12 87 | 88 | 89 | 1234 90 | 2017-01-20 91 | 92 | 93 | 94 | 95 | BANKNL2A 96 | 97 | 98 | 99 | Test von Testenstein 100 | 101 | 102 | 103 | NL50BANK1234567890 104 | 105 | 106 | 107 | Test transaction1 108 | 109 | 110 | 111 | 112 | TestCreditor-d547a1b3882f 113 | DD 114 | false 115 | 1 116 | 50.00 117 | 118 | 119 | SEPA 120 | 121 | 122 | CORE 123 | 124 | RCUR 125 | 126 | 2017-01-20 127 | 128 | TestCreditor 129 | 130 | 131 | 132 | NL50BANK1234567890 133 | 134 | 135 | 136 | 137 | BANKNL2A 138 | 139 | 140 | SLEV 141 | 142 | 143 | 144 | 145 | DE26ZZZ00000000000 146 | 147 | SEPA 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | TestCreditor-7e989083e265 156 | 157 | 50.00 158 | 159 | 160 | 1234 161 | 2017-01-20 162 | 163 | 164 | 165 | 166 | BANKNL2A 167 | 168 | 169 | 170 | Test du Test 171 | 172 | 173 | 174 | NL50BANK1234567890 175 | 176 | 177 | 178 | Test transaction2 179 | 180 | 181 | 182 | 183 | 184 | """ 185 | 186 | 187 | def test_two_debits(sdd): 188 | payment1 = { 189 | "name": "Test von Testenstein", 190 | "IBAN": "NL50BANK1234567890", 191 | "BIC": "BANKNL2A", 192 | "amount": 1012, 193 | "type": "FRST", 194 | "collection_date": datetime.date.today(), 195 | "mandate_id": "1234", 196 | "mandate_date": datetime.date.today(), 197 | "description": "Test transaction1" 198 | } 199 | payment2 = { 200 | "name": "Test du Test", 201 | "IBAN": "NL50BANK1234567890", 202 | "BIC": "BANKNL2A", 203 | "amount": 5000, 204 | "type": "RCUR", 205 | "collection_date": datetime.date.today(), 206 | "mandate_id": "1234", 207 | "mandate_date": datetime.date.today(), 208 | "description": "Test transaction2" 209 | } 210 | 211 | sdd.add_payment(payment1) 212 | sdd.add_payment(payment2) 213 | xmlout = sdd.export() 214 | xmlpretty = validate_xml(xmlout, "pain.008.001.02") 215 | assert clean_ids(xmlpretty.strip()) == clean_ids(SAMPLE_RESULT.strip()) 216 | -------------------------------------------------------------------------------- /tests/debit/test_timestamps.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | import datetime 4 | from unittest import mock 5 | 6 | import pytest 7 | 8 | from sepaxml import SepaDD 9 | from tests.utils import validate_xml 10 | 11 | 12 | @pytest.fixture 13 | def freeze_random(): 14 | import random 15 | 16 | with mock.patch("sepaxml.utils.random", random.Random(123456)): 17 | yield 18 | 19 | 20 | @pytest.fixture 21 | def now(): 22 | return datetime.datetime(2021, 10, 2, 20, 17, 35, tzinfo=datetime.timezone.utc) 23 | 24 | 25 | @pytest.fixture 26 | def today(now): 27 | return now.date() 28 | 29 | 30 | @pytest.fixture 31 | def freeze_datetime(now): 32 | _datetime = mock.Mock( 33 | date=datetime.date, 34 | datetime=mock.Mock(now=mock.Mock(return_value=now)), 35 | ) 36 | with mock.patch("sepaxml.debit.datetime", _datetime), mock.patch( 37 | "sepaxml.utils.datetime", _datetime 38 | ): 39 | yield 40 | 41 | 42 | @pytest.fixture 43 | def sdd(): 44 | return SepaDD({ 45 | "name": "Miller & Son Ltd", 46 | "IBAN": "NL50BANK1234567890", 47 | "BIC": "BANKNL2A", 48 | "batch": True, 49 | "creditor_id": "DE26ZZZ00000000000", 50 | "currency": "EUR" 51 | }, schema="pain.008.001.02") 52 | 53 | 54 | SAMPLE_RESULT = b""" 55 | 56 | 57 | 58 | 20211002081735-9050218037f5 59 | 2021-10-02T20:17:35 60 | 2 61 | 60.12 62 | 63 | Miller & Son Ltd 64 | 65 | 66 | 67 | DE26ZZZ00000000000 68 | 69 | 70 | 71 | 72 | 73 | 74 | MillerSonLtd-04cb151eee51 75 | DD 76 | true 77 | 1 78 | 10.12 79 | 80 | 81 | SEPA 82 | 83 | 84 | CORE 85 | 86 | FRST 87 | 88 | 2021-10-02 89 | 90 | Miller & Son Ltd 91 | 92 | 93 | 94 | NL50BANK1234567890 95 | 96 | 97 | 98 | 99 | BANKNL2A 100 | 101 | 102 | SLEV 103 | 104 | 105 | 106 | 107 | DE26ZZZ00000000000 108 | 109 | SEPA 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | ebd75e7e649375d91b33dc11ae44c0e1 118 | 119 | 10.12 120 | 121 | 122 | 1234 123 | 2021-10-02 124 | 125 | 126 | 127 | 128 | BANKNL2A 129 | 130 | 131 | 132 | Test & Co. 133 | 134 | 135 | 136 | NL50BANK1234567890 137 | 138 | 139 | 140 | Test transaction1 141 | 142 | 143 | 144 | 145 | MillerSonLtd-323224a9eab8 146 | DD 147 | true 148 | 1 149 | 50.00 150 | 151 | 152 | SEPA 153 | 154 | 155 | CORE 156 | 157 | RCUR 158 | 159 | 2021-10-02 160 | 161 | Miller & Son Ltd 162 | 163 | 164 | 165 | NL50BANK1234567890 166 | 167 | 168 | 169 | 170 | BANKNL2A 171 | 172 | 173 | SLEV 174 | 175 | 176 | 177 | 178 | DE26ZZZ00000000000 179 | 180 | SEPA 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | af755a40cb692551ed9f9d55f7179525 189 | 190 | 50.00 191 | 192 | 193 | 1234 194 | 2021-10-02 195 | 196 | 197 | 198 | 199 | BANKNL2A 200 | 201 | 202 | 203 | Test du Test 204 | 205 | 206 | 207 | NL50BANK1234567890 208 | 209 | 210 | 211 | Testgrusse <html> 212 | 213 | 214 | 215 | 216 | 217 | """ 218 | 219 | 220 | @pytest.mark.usefixtures("freeze_random", "freeze_datetime") 221 | def test_two_debits(sdd, today): 222 | payment1 = { 223 | "name": "Test & Co.", 224 | "IBAN": "NL50BANK1234567890", 225 | "BIC": "BANKNL2A", 226 | "amount": 1012, 227 | "type": "FRST", 228 | "collection_date": today, 229 | "mandate_id": "1234", 230 | "mandate_date": today, 231 | "description": "Test transaction1", 232 | "endtoend_id": "ebd75e7e649375d91b33dc11ae44c0e1" 233 | } 234 | payment2 = { 235 | "name": "Test du Test", 236 | "IBAN": "NL50BANK1234567890", 237 | "BIC": "BANKNL2A", 238 | "amount": 5000, 239 | "type": "RCUR", 240 | "collection_date": today, 241 | "mandate_id": "1234", 242 | "mandate_date": today, 243 | "description": u"Testgrüße ", 244 | "endtoend_id": "af755a40cb692551ed9f9d55f7179525" 245 | } 246 | 247 | sdd.add_payment(payment1) 248 | sdd.add_payment(payment2) 249 | xmlout = sdd.export() 250 | xmlpretty = validate_xml(xmlout, "pain.008.001.02") 251 | assert xmlpretty.strip() == SAMPLE_RESULT.strip() 252 | -------------------------------------------------------------------------------- /tests/transfer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raphaelm/python-sepaxml/09063d3d61b6a1c392d46a1312ad02ef069175f2/tests/transfer/__init__.py -------------------------------------------------------------------------------- /tests/transfer/test_00100103.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | from sepaxml import SepaTransfer 6 | from tests.utils import clean_ids, validate_xml 7 | 8 | 9 | @pytest.fixture 10 | def strf(): 11 | return SepaTransfer({ 12 | "name": "TestCreditor", 13 | "IBAN": "NL50BANK1234567890", 14 | "BIC": "BANKNL2A", 15 | "batch": True, 16 | "currency": "EUR" 17 | }, schema="pain.001.001.03") 18 | 19 | 20 | SAMPLE_RESULT = b""" 21 | 22 | 23 | 24 | 20180724040432-d24ce3b3e284 25 | 2018-07-24T16:04:32 26 | 2 27 | 60.12 28 | 29 | TestCreditor 30 | 31 | 32 | 33 | TestCreditor-90102652f82a 34 | TRF 35 | true 36 | 2 37 | 60.12 38 | 39 | 40 | SEPA 41 | 42 | 43 | 2018-07-24 44 | 45 | TestCreditor 46 | 47 | 48 | 49 | NL50BANK1234567890 50 | 51 | 52 | 53 | 54 | BANKNL2A 55 | 56 | 57 | SLEV 58 | 59 | 60 | NOTPROVIDED 61 | 62 | 63 | 10.12 64 | 65 | 66 | 67 | BANKNL2A 68 | 69 | 70 | 71 | Test von Testenstein 72 | 73 | 74 | 75 | NL50BANK1234567890 76 | 77 | 78 | 79 | Test transaction1 80 | 81 | 82 | 83 | 84 | NOTPROVIDED 85 | 86 | 87 | 50.00 88 | 89 | 90 | 91 | BANKNL2A 92 | 93 | 94 | 95 | Test du Test 96 | 97 | 98 | 99 | NL50BANK1234567890 100 | 101 | 102 | 103 | Test transaction2 104 | 105 | 106 | 107 | 108 | 109 | """ 110 | 111 | 112 | def test_two_debits(strf): 113 | payment1 = { 114 | "name": "Test von Testenstein", 115 | "IBAN": "NL50BANK1234567890", 116 | "BIC": "BANKNL2A", 117 | "amount": 1012, 118 | "execution_date": datetime.date.today(), 119 | "description": "Test transaction1" 120 | } 121 | payment2 = { 122 | "name": "Test du Test", 123 | "IBAN": "NL50BANK1234567890", 124 | "BIC": "BANKNL2A", 125 | "amount": 5000, 126 | "execution_date": datetime.date.today(), 127 | "description": "Test transaction2" 128 | } 129 | 130 | strf.add_payment(payment1) 131 | strf.add_payment(payment2) 132 | xmlout = strf.export() 133 | xmlpretty = validate_xml(xmlout, "pain.001.001.03") 134 | assert clean_ids(xmlpretty.strip()) == clean_ids(SAMPLE_RESULT.strip()) 135 | -------------------------------------------------------------------------------- /tests/transfer/test_config.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from sepaxml import SepaTransfer 4 | 5 | 6 | def test_valid_config(): 7 | return SepaTransfer({ 8 | "name": "TestCreditor", 9 | "IBAN": "NL50BANK1234567890", 10 | "BIC": "BANKNL2A", 11 | "batch": True, 12 | "currency": "EUR" 13 | }) 14 | 15 | 16 | def test_invalid_config(): 17 | with pytest.raises(Exception): 18 | return SepaTransfer({ 19 | "name": "TestCreditor", 20 | "BIC": "BANKNL2A", 21 | "batch": True, 22 | "currency": "EUR" 23 | }) 24 | -------------------------------------------------------------------------------- /tests/transfer/test_domestic.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | from sepaxml import SepaTransfer 6 | from tests.utils import clean_ids, validate_xml 7 | 8 | 9 | @pytest.fixture 10 | def strf(): 11 | return SepaTransfer({ 12 | "name": "TestCreditor", 13 | "IBAN": "CH4912345123456789012", 14 | "batch": True, 15 | "domestic": True, 16 | "currency": "CHF" 17 | }, schema="pain.001.001.03") 18 | 19 | 20 | SAMPLE_RESULT = b""" 21 | 22 | 23 | 24 | 20180724040432-d24ce3b3e284 25 | 2018-07-24T16:04:32 26 | 2 27 | 60.12 28 | 29 | TestCreditor 30 | 31 | 32 | 33 | TestCreditor-90102652f82a 34 | TRF 35 | true 36 | 2 37 | 60.12 38 | 2018-07-24 39 | 40 | TestCreditor 41 | 42 | 43 | 44 | CH4912345123456789012 45 | 46 | 47 | 48 | 49 | 50 | SLEV 51 | 52 | 53 | NOTPROVIDED 54 | 55 | 56 | 10.12 57 | 58 | 59 | Test von Testenstein 60 | 61 | 62 | 63 | CH4912345123456789012 64 | 65 | 66 | 67 | Test transaction1 68 | 69 | 70 | 71 | 72 | NOTPROVIDED 73 | 74 | 75 | 50.00 76 | 77 | 78 | Test du Test 79 | 80 | 81 | 82 | CH6911111222222222222 83 | 84 | 85 | 86 | Test transaction2 87 | 88 | 89 | 90 | 91 | 92 | """ 93 | 94 | 95 | def test_two_domestic_debits(strf): 96 | payment1 = { 97 | "name": "Test von Testenstein", 98 | "IBAN": "CH4912345123456789012", 99 | "amount": 1012, 100 | "execution_date": datetime.date.today(), 101 | "description": "Test transaction1" 102 | } 103 | payment2 = { 104 | "name": "Test du Test", 105 | "IBAN": "CH6911111222222222222", 106 | "amount": 5000, 107 | "execution_date": datetime.date.today(), 108 | "description": "Test transaction2" 109 | } 110 | 111 | strf.add_payment(payment1) 112 | strf.add_payment(payment2) 113 | xmlout = strf.export() 114 | xmlpretty = validate_xml(xmlout, "pain.001.001.03") 115 | assert clean_ids(xmlpretty.strip()).decode() == clean_ids(SAMPLE_RESULT.strip()).decode() 116 | -------------------------------------------------------------------------------- /tests/transfer/test_endtoendid.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | import datetime 4 | 5 | import pytest 6 | 7 | from sepaxml import SepaTransfer 8 | from tests.utils import clean_ids, validate_xml 9 | 10 | 11 | @pytest.fixture 12 | def strf(): 13 | return SepaTransfer({ 14 | "name": "Miller & Son Ltd", 15 | "IBAN": "NL50BANK1234567890", 16 | "BIC": "BANKNL2A", 17 | "batch": True, 18 | "currency": "EUR" 19 | }, schema="pain.001.001.03") 20 | 21 | 22 | SAMPLE_RESULT = b""" 23 | 24 | 25 | 26 | 20180724041136-3b840ce62087 27 | 2018-07-24T16:11:36 28 | 2 29 | 20.24 30 | 31 | Miller & Son Ltd 32 | 33 | 34 | 35 | MillerSonLtd-67c22f433a9e 36 | TRF 37 | true 38 | 2 39 | 20.24 40 | 41 | 42 | SEPA 43 | 44 | 45 | 2018-07-24 46 | 47 | Miller & Son Ltd 48 | 49 | 50 | 51 | NL50BANK1234567890 52 | 53 | 54 | 55 | 56 | BANKNL2A 57 | 58 | 59 | SLEV 60 | 61 | 62 | ebd75e7e649375d91b33dc11ae44c0e1 63 | 64 | 65 | 10.12 66 | 67 | 68 | 69 | BANKNL2A 70 | 71 | 72 | 73 | Test von Testenstein 74 | 75 | 76 | 77 | NL50BANK1234567890 78 | 79 | 80 | 81 | Test transaction1 82 | 83 | 84 | 85 | 86 | af755a40cb692551ed9f9d55f7179525 87 | 88 | 89 | 10.12 90 | 91 | 92 | 93 | BANKNL2A 94 | 95 | 96 | 97 | Test von Testenstein 98 | 99 | 100 | 101 | NL50BANK1234567890 102 | 103 | 104 | 105 | Test transaction1 106 | 107 | 108 | 109 | 110 | 111 | """ 112 | 113 | 114 | def test_two_debits(strf): 115 | payment1 = { 116 | "endtoend_id": "ebd75e7e649375d91b33dc11ae44c0e1", 117 | "name": "Test von Testenstein", 118 | "IBAN": "NL50BANK1234567890", 119 | "BIC": "BANKNL2A", 120 | "amount": 1012, 121 | "execution_date": datetime.date.today(), 122 | "description": "Test transaction1" 123 | } 124 | payment2 = { 125 | "name": "Test von Testenstein", 126 | "IBAN": "NL50BANK1234567890", 127 | "BIC": "BANKNL2A", 128 | "amount": 1012, 129 | "execution_date": datetime.date.today(), 130 | "description": "Test transaction1", 131 | "endtoend_id": "af755a40cb692551ed9f9d55f7179525" 132 | } 133 | 134 | strf.add_payment(payment1) 135 | strf.add_payment(payment2) 136 | xmlout = strf.export() 137 | xmlpretty = validate_xml(xmlout, "pain.001.001.03") 138 | assert clean_ids(xmlpretty.strip()) == clean_ids(SAMPLE_RESULT.strip()) 139 | -------------------------------------------------------------------------------- /tests/transfer/test_no_bic.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | from sepaxml import SepaTransfer 6 | from tests.utils import clean_ids, validate_xml 7 | 8 | 9 | @pytest.fixture 10 | def strf(): 11 | return SepaTransfer({ 12 | "name": "TestCreditor", 13 | "IBAN": "NL50BANK1234567890", 14 | "batch": True, 15 | "currency": "EUR" 16 | }, schema="pain.001.001.03") 17 | 18 | 19 | SAMPLE_RESULT = b""" 20 | 21 | 22 | 23 | 20180724040432-d24ce3b3e284 24 | 2018-07-24T16:04:32 25 | 2 26 | 60.12 27 | 28 | TestCreditor 29 | 30 | 31 | 32 | TestCreditor-90102652f82a 33 | TRF 34 | true 35 | 2 36 | 60.12 37 | 38 | 39 | SEPA 40 | 41 | 42 | 2018-07-24 43 | 44 | TestCreditor 45 | 46 | 47 | 48 | NL50BANK1234567890 49 | 50 | 51 | 52 | 53 | 54 | SLEV 55 | 56 | 57 | NOTPROVIDED 58 | 59 | 60 | 10.12 61 | 62 | 63 | Test von Testenstein 64 | 65 | 66 | 67 | NL50BANK1234567890 68 | 69 | 70 | 71 | Test transaction1 72 | 73 | 74 | 75 | 76 | NOTPROVIDED 77 | 78 | 79 | 50.00 80 | 81 | 82 | Test du Test 83 | 84 | 85 | 86 | NL50BANK1234567890 87 | 88 | 89 | 90 | Test transaction2 91 | 92 | 93 | 94 | 95 | 96 | """ 97 | 98 | 99 | def test_two_transfers(strf): 100 | payment1 = { 101 | "name": "Test von Testenstein", 102 | "IBAN": "NL50BANK1234567890", 103 | "amount": 1012, 104 | "execution_date": datetime.date.today(), 105 | "description": "Test transaction1" 106 | } 107 | payment2 = { 108 | "name": "Test du Test", 109 | "IBAN": "NL50BANK1234567890", 110 | "amount": 5000, 111 | "execution_date": datetime.date.today(), 112 | "description": "Test transaction2" 113 | } 114 | 115 | strf.add_payment(payment1) 116 | strf.add_payment(payment2) 117 | xmlout = strf.export() 118 | xmlpretty = validate_xml(xmlout, "pain.001.001.03") 119 | assert clean_ids(xmlpretty.strip()).decode() == clean_ids(SAMPLE_RESULT.strip()).decode() 120 | -------------------------------------------------------------------------------- /tests/transfer/test_non_batch.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | from sepaxml import SepaTransfer 6 | from tests.utils import clean_ids, validate_xml 7 | 8 | 9 | @pytest.fixture 10 | def strf(): 11 | return SepaTransfer({ 12 | "name": "TestCreditor", 13 | "IBAN": "NL50BANK1234567890", 14 | "BIC": "BANKNL2A", 15 | "batch": False, 16 | "currency": "EUR" 17 | }, schema="pain.001.001.03") 18 | 19 | 20 | SAMPLE_RESULT = b""" 21 | 22 | 23 | 24 | 20180724041334-4db42f0dd97e 25 | 2018-07-24T16:13:34 26 | 2 27 | 20.24 28 | 29 | TestCreditor 30 | 31 | 32 | 33 | TestCreditor-8748725a0019 34 | TRF 35 | false 36 | 1 37 | 10.12 38 | 39 | 40 | SEPA 41 | 42 | 43 | 2018-07-24 44 | 45 | TestCreditor 46 | 47 | 48 | 49 | NL50BANK1234567890 50 | 51 | 52 | 53 | 54 | BANKNL2A 55 | 56 | 57 | SLEV 58 | 59 | 60 | ebd75e7e649375d91b33dc11ae44c0e1 61 | 62 | 63 | 10.12 64 | 65 | 66 | 67 | BANKNL2A 68 | 69 | 70 | 71 | Test von Testenstein 72 | 73 | 74 | 75 | NL50BANK1234567890 76 | 77 | 78 | 79 | Test transaction1 80 | 81 | 82 | 83 | 84 | TestCreditor-6ecc5522bddd 85 | TRF 86 | false 87 | 1 88 | 10.12 89 | 90 | 91 | SEPA 92 | 93 | 94 | 2018-07-24 95 | 96 | TestCreditor 97 | 98 | 99 | 100 | NL50BANK1234567890 101 | 102 | 103 | 104 | 105 | BANKNL2A 106 | 107 | 108 | SLEV 109 | 110 | 111 | af755a40cb692551ed9f9d55f7179525 112 | 113 | 114 | 10.12 115 | 116 | 117 | 118 | BANKNL2A 119 | 120 | 121 | 122 | Test von Testenstein 123 | 124 | 125 | 126 | NL50BANK1234567890 127 | 128 | 129 | 130 | Test transaction1 131 | 132 | 133 | 134 | 135 | 136 | """ 137 | 138 | 139 | def test_two_debits(strf): 140 | payment1 = { 141 | "endtoend_id": "ebd75e7e649375d91b33dc11ae44c0e1", 142 | "name": "Test von Testenstein", 143 | "IBAN": "NL50BANK1234567890", 144 | "BIC": "BANKNL2A", 145 | "amount": 1012, 146 | "execution_date": datetime.date.today(), 147 | "description": "Test transaction1" 148 | } 149 | payment2 = { 150 | "name": "Test von Testenstein", 151 | "IBAN": "NL50BANK1234567890", 152 | "BIC": "BANKNL2A", 153 | "amount": 1012, 154 | "execution_date": datetime.date.today(), 155 | "description": "Test transaction1", 156 | "endtoend_id": "af755a40cb692551ed9f9d55f7179525" 157 | } 158 | 159 | strf.add_payment(payment1) 160 | strf.add_payment(payment2) 161 | xmlout = strf.export() 162 | xmlpretty = validate_xml(xmlout, "pain.001.001.03") 163 | print(xmlpretty.decode()) 164 | assert clean_ids(xmlpretty.strip()) == clean_ids(SAMPLE_RESULT.strip()) 165 | 166 | 167 | def test_sepa_address(strf): 168 | config = { 169 | "name": "TestCreditor", 170 | "IBAN": "NL50BANK1234567890", 171 | "BIC": "BANKNL2A", 172 | "batch": False, 173 | "currency": "EUR", 174 | "address": { 175 | "country": "DE", 176 | "lines": ["Line 1", "Line 2"], 177 | }, 178 | } 179 | payment1 = { 180 | "endtoend_id": "ebd75e7e649375d91b33dc11ae44c0e1", 181 | "name": "Test von Testenstein", 182 | "IBAN": "NL50BANK1234567890", 183 | "BIC": "BANKNL2A", 184 | "amount": 1012, 185 | "execution_date": datetime.date.today(), 186 | "description": "Test transaction1", 187 | "address": { 188 | "country": "DE", 189 | "lines": ["Line 1", "Line 2"], 190 | }, 191 | } 192 | strf = SepaTransfer(config) 193 | strf.add_payment(payment1) 194 | -------------------------------------------------------------------------------- /tests/transfer/test_timestamps.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | import datetime 4 | from unittest import mock 5 | 6 | import pytest 7 | 8 | from sepaxml import SepaTransfer 9 | from tests.utils import validate_xml 10 | 11 | 12 | @pytest.fixture 13 | def freeze_random(): 14 | import random 15 | 16 | with mock.patch("sepaxml.utils.random", random.Random(123456)): 17 | yield 18 | 19 | 20 | @pytest.fixture 21 | def now(): 22 | return datetime.datetime(2021, 10, 2, 20, 17, 35, tzinfo=datetime.timezone.utc) 23 | 24 | 25 | @pytest.fixture 26 | def today(now): 27 | return now.date() 28 | 29 | 30 | @pytest.fixture 31 | def freeze_datetime(now): 32 | _datetime = mock.Mock( 33 | date=datetime.date, 34 | datetime=mock.Mock(now=mock.Mock(return_value=now)), 35 | ) 36 | with mock.patch("sepaxml.transfer.datetime", _datetime), mock.patch( 37 | "sepaxml.utils.datetime", _datetime 38 | ): 39 | yield 40 | 41 | 42 | @pytest.fixture 43 | def strf(): 44 | return SepaTransfer({ 45 | "name": "Miller & Son Ltd", 46 | "IBAN": "NL50BANK1234567890", 47 | "BIC": "BANKNL2A", 48 | "batch": True, 49 | "currency": "EUR" 50 | }, schema="pain.001.001.03") 51 | 52 | 53 | SAMPLE_RESULT = b""" 54 | 55 | 56 | 57 | 20211002081735-9050218037f5 58 | 2021-10-02T20:17:35 59 | 2 60 | 30.60 61 | 62 | Miller & Son Ltd 63 | 64 | 65 | 66 | MillerSonLtd-04cb151eee51 67 | TRF 68 | true 69 | 1 70 | 10.12 71 | 72 | 73 | SEPA 74 | 75 | 76 | 2021-10-02 77 | 78 | Miller & Son Ltd 79 | 80 | 81 | 82 | NL50BANK1234567890 83 | 84 | 85 | 86 | 87 | BANKNL2A 88 | 89 | 90 | SLEV 91 | 92 | 93 | ebd75e7e649375d91b33dc11ae44c0e1 94 | 95 | 96 | 10.12 97 | 98 | 99 | 100 | BANKNL2A 101 | 102 | 103 | 104 | Test von Testenstein 105 | 106 | 107 | 108 | NL50BANK1234567890 109 | 110 | 111 | 112 | Test transaction1 113 | 114 | 115 | 116 | 117 | MillerSonLtd-323224a9eab8 118 | TRF 119 | true 120 | 1 121 | 20.48 122 | 123 | 124 | SEPA 125 | 126 | 127 | 2021-10-03 128 | 129 | Miller & Son Ltd 130 | 131 | 132 | 133 | NL50BANK1234567890 134 | 135 | 136 | 137 | 138 | BANKNL2A 139 | 140 | 141 | SLEV 142 | 143 | 144 | af755a40cb692551ed9f9d55f7179525 145 | 146 | 147 | 20.48 148 | 149 | 150 | 151 | BANKNL2A 152 | 153 | 154 | 155 | Test von Testenstein 156 | 157 | 158 | 159 | NL50BANK1234567890 160 | 161 | 162 | 163 | Test transaction2 164 | 165 | 166 | 167 | 168 | 169 | """ 170 | 171 | 172 | @pytest.mark.usefixtures("freeze_random", "freeze_datetime") 173 | def test_two_debits(strf, today): 174 | payment1 = { 175 | "name": "Test von Testenstein", 176 | "IBAN": "NL50BANK1234567890", 177 | "BIC": "BANKNL2A", 178 | "amount": 1012, 179 | "execution_date": today, 180 | "description": "Test transaction1", 181 | "endtoend_id": "ebd75e7e649375d91b33dc11ae44c0e1", 182 | } 183 | payment2 = { 184 | "name": "Test von Testenstein", 185 | "IBAN": "NL50BANK1234567890", 186 | "BIC": "BANKNL2A", 187 | "amount": 2048, 188 | "execution_date": today + datetime.timedelta(days=1), 189 | "description": "Test transaction2", 190 | "endtoend_id": "af755a40cb692551ed9f9d55f7179525", 191 | } 192 | 193 | strf.add_payment(payment1) 194 | strf.add_payment(payment2) 195 | xmlout = strf.export() 196 | xmlpretty = validate_xml(xmlout, "pain.001.001.03") 197 | assert xmlpretty.strip() == SAMPLE_RESULT.strip() 198 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | from lxml import etree 5 | 6 | from sepaxml import validation 7 | 8 | 9 | def validate_xml(xmlout, schema): 10 | with open(os.path.join(os.path.dirname(validation.__file__), 'schemas', schema + '.xsd'), 'rb') as schema_file: 11 | schema_xml = schema_file.read() 12 | schema_root = etree.XML(schema_xml) 13 | schema = etree.XMLSchema(schema_root) 14 | parser = etree.XMLParser(schema=schema) 15 | xml_root = etree.fromstring(xmlout, parser) 16 | return etree.tostring(xml_root, pretty_print=True) 17 | 18 | 19 | def clean_ids(xmlout): 20 | pat1 = re.compile(b'-[0-9a-f]{12}') 21 | pat2 = re.compile(b'[^<]*') 22 | pat3 = re.compile(b'\\d\\d\\d\\d-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\d') 23 | pat4 = re.compile(b'\\d\\d\\d\\d-\\d\\d-\\d\\d') 24 | return pat4.sub(b'0000-00-00', pat3.sub(b'0000-00-00T00:00:00', pat2.sub(b'', pat1.sub(b'-000000000000', xmlout)))) 25 | --------------------------------------------------------------------------------