├── .github └── workflows │ └── test.yml ├── .gitignore ├── CHANGELOG.rst ├── LICENSE.TXT ├── README.rst ├── qrbill ├── __init__.py └── bill.py ├── scripts └── qrbill ├── setup.cfg ├── setup.py └── tests ├── __init__.py └── test_qrbill.py /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | pull_request: 4 | push: 5 | branches: [ master ] 6 | jobs: 7 | lint: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: actions/setup-python@v5 12 | with: 13 | python-version: "3.11" 14 | - name: Install dependencies 15 | run: | 16 | python -m pip install --upgrade pip 17 | python -m pip install flake8 18 | - name: Lint with flake8 19 | run: | 20 | flake8 . 21 | 22 | test: 23 | runs-on: ubuntu-latest 24 | strategy: 25 | matrix: 26 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 27 | steps: 28 | - uses: actions/checkout@v4 29 | - name: Set up Python ${{ matrix.python-version }} 30 | uses: actions/setup-python@v5 31 | with: 32 | python-version: ${{ matrix.python-version }} 33 | - name: Install dependencies 34 | run: | 35 | python -m pip install --upgrade pip 36 | python -m pip install -e . 37 | - name: tests 38 | run: python ./tests/test_qrbill.py 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info/ 2 | __pycache__/ 3 | /build/ 4 | /dist/ 5 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | .. -*- mode: rst -*- 2 | 3 | ChangeLog 4 | ========= 5 | 6 | 1.1.0 (2023-12-16) 7 | ------------------ 8 | - Add Arial font name in addition to Helvetica for better font fallback on some 9 | systems. 10 | - Drop support for Python < 3.8, and add testing for Python 3.11 and 3.12. 11 | 12 | 1.0.0 (2022-09-21) 13 | ------------------ 14 | - BREAKING: Removed the ``due-date`` command line argument and the ``due_date`` 15 | QRBill init kwarg, as this field is no longer in the most recent specs (#84). 16 | - Handle line breaks in additional information, so it is showing in the printed 17 | version, but stripped from the QR data (#86). 18 | - Improved performance by deactivating debug mode in svgwrite (#82). 19 | 20 | 0.8.1 (2022-05-10) 21 | ------------------ 22 | - Fixed a regression where the currency was not visible in the payment part 23 | (#81). 24 | 25 | 0.8.0 (2022-04-13) 26 | ------------------ 27 | - Replaced ``##`` with ``//`` as separator in additional informations (#75). 28 | - Print scissors symbol on horizontal separation line when not in full page. 29 | WARNING: the resulting bill is 1 millimiter higher to be able to show the 30 | entire symbol (#65). 31 | - Renamed ``--extra-infos`` command line parameter to ``--additional-information`` 32 | and renamed ``extra_infos`` and ``ref_number`` ``QRBill.__init__`` arguments 33 | to ``additional_information`` and ``reference_number``, respectively. 34 | The old arguments are still accepted but raise a deprecation warning (#68). 35 | 36 | 0.7.1 (2022-03-07) 37 | ------------------ 38 | - Fixed bad position of amount rect on receipt part (#74). 39 | - Increased title font size and section spacing on payment part. 40 | 41 | 0.7.0 (2021-12-18) 42 | ------------------ 43 | - License changed from GPL to MIT (#72). 44 | - Prevented separation line filled on some browsers. 45 | - Scissors symbol is now an SVG path (#46). 46 | 47 | 0.6.1 (2021-05-01) 48 | ------------------ 49 | - Added ``--version`` command-line option. 50 | - QR-code size is now more in line with the specs, including the embedded Swiss 51 | cross (#58, #59). 52 | - Widen space at the right of the QR-code (#57). 53 | - A new ``--font-factor`` command-line option allows to scale the font if the 54 | actual size does not fit your needs (#55). 55 | 56 | 0.6.0 (2021-02-11) 57 | ------------------ 58 | - Added the possibility to include newline sequences in name, street, line1, or 59 | line2 part of addresses to improve printed line wrapping of long lines. 60 | - Moved QR-code and amount section to better comply with the style guide (#52). 61 | - Dropped support for EOL Python 3.5 and confirmed support for Python 3.9. 62 | 63 | 0.5.3 (2021-01-25) 64 | ------------------ 65 | - Enforced black as swiss cross background color. 66 | - Allowed output with extension other than .svg (warning instead of error). 67 | - Split long address lines to fit in available space (#48). 68 | 69 | 0.5.2 (2020-11-17) 70 | ------------------ 71 | 72 | - Final creditor is only for future use, it was removed from command line 73 | parameters. 74 | - Capitalized Helvetica font name in code (#43). 75 | - The top line was printed a bit lower to be more visible (#42). 76 | 77 | 0.5.1 (2020-08-19) 78 | ------------------ 79 | 80 | - Fix for missing country field in QR code when using CombinedAddress (#31). 81 | - Added support for printing bill to full A4 format, using the ``full_page`` 82 | parameter of ``QRBill.as_svg()`` or the CLI argument ``--full-page``. 83 | - The vertical separation line between receipt and main part can be omitted 84 | through the ``--no-payment-line`` CLI argument. 85 | - A new ``--text`` command line parameter allows for a raw text output. 86 | - Support for Alternate procedures lines was added (``--alt-procs`` argument, 87 | #40). 88 | 89 | 0.5 (2020-06-24) 90 | ---------------- 91 | 92 | - ``QRBill.as_svg()`` accepts now file-like objects. 93 | - Added support for combined address format. 94 | - A top separation line is now printed by default. It can be deactivated 95 | through the ``top_line`` boolean parameter of ``QRBill.__init__()``. 96 | - The error correction level of the QR code conforms now to the spec (M). 97 | 98 | 0.4 (2020-02-24) 99 | ---------------- 100 | 101 | Changes were not logged until version 0.4. Development stage was still alpha. 102 | -------------------------------------------------------------------------------- /LICENSE.TXT: -------------------------------------------------------------------------------- 1 | Copyright 2017-2021 Claude Paroz and other individual contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://travis-ci.com/claudep/swiss-qr-bill.svg?branch=master 2 | :target: https://travis-ci.com/claudep/swiss-qr-bill 3 | .. image:: https://img.shields.io/pypi/v/qrbill.svg 4 | :target: https://pypi.python.org/pypi/qrbill/ 5 | 6 | Python library to generate Swiss QR-bills 7 | ========================================= 8 | 9 | From 2020, Swiss payment slips will progressively be converted to the 10 | QR-bill format. 11 | Specifications can be found on https://www.paymentstandards.ch/ 12 | 13 | This library is aimed to produce properly-formatted QR-bills as SVG files 14 | either from command line input or by using the ``QRBill`` class. 15 | 16 | Installation 17 | ============ 18 | 19 | You can easily install this library with:: 20 | 21 | $ pip install qrbill 22 | 23 | Command line usage example 24 | ========================== 25 | 26 | Minimal:: 27 | 28 | $ qrbill --account "CH5800791123000889012" --creditor-name "John Doe" 29 | --creditor-postalcode 2501 --creditor-city "Biel" 30 | 31 | More complete:: 32 | 33 | $ qrbill --account "CH44 3199 9123 0008 8901 2" --reference-number "210000000003139471430009017" 34 | --creditor-name "Robert Schneider AG" --creditor-street "Rue du Lac 1268" 35 | --creditor-postalcode "2501" --creditor-city "Biel" 36 | --additional-information "Bill No. 3139 for garden work and disposal of cuttings." 37 | --debtor-name "Pia Rutschmann" --debtor-street "Marktgasse 28" --debtor-postalcode "9400" 38 | --debtor-city "Rorschach" --language "de" 39 | 40 | For usage:: 41 | 42 | $ qrbill -h 43 | 44 | If no `--output` SVG file path is specified, the SVG file will be named after 45 | the account and the current date/time and written in the current directory. 46 | 47 | Note that if you don't like the automatic line wrapping in the human-readable 48 | part of some address, you can replace a space by a newline sequence in the 49 | creditor or debtor name, line1, line2, or street to force a line break in the 50 | printed addresses. 51 | (e.g. `--creditor-street "Rue des Quatorze Contours du Chemin\ndu Creux du Van"`) 52 | The data encoded in the QR bill will *not* have the newline character. It will 53 | be replaced by a regular space. 54 | 55 | Python usage example 56 | ==================== 57 | 58 | :: 59 | 60 | >>> from qrbill import QRBill 61 | >>> my_bill = QRBill( 62 | account='CH5800791123000889012', 63 | creditor={ 64 | 'name': 'Jane', 'pcode': '1000', 'city': 'Lausanne', 'country': 'CH', 65 | }, 66 | amount='22.45', 67 | ) 68 | >>> my_bill.as_svg('/tmp/my_bill.svg') 69 | 70 | Outputting as PDF or bitmap 71 | =========================== 72 | 73 | If you want to produce a PDF version of the resulting bill, we suggest using the 74 | `svglib ` library. It can be used on the 75 | command line with the `svg2pdf` script, or directly from Python:: 76 | 77 | >>> import tempfile 78 | >>> from qrbill import QRBill 79 | >>> from svglib.svglib import svg2rlg 80 | >>> from reportlab.graphics import renderPDF 81 | 82 | >>> my_bill = QRBill( 83 | account='CH5800791123000889012', 84 | creditor={ 85 | 'name': 'Jane', 'pcode': '1000', 'city': 'Lausanne', 'country': 'CH', 86 | }, 87 | amount='22.45', 88 | ) 89 | >>> with tempfile.TemporaryFile(encoding='utf-8', mode='r+') as temp: 90 | >>> my_bill.as_svg(temp) 91 | >>> temp.seek(0) 92 | >>> drawing = svg2rlg(temp) 93 | >>> renderPDF.drawToFile(drawing, "file.pdf") 94 | 95 | or to produce a bitmap image output:: 96 | 97 | >>> from reportlab.graphics import renderPM 98 | >>> dpi = 300 99 | >>> drawing.scale(dpi/72, dpi/72) 100 | >>> renderPM.drawToFile(drawing, "file.png", fmt='PNG', dpi=dpi) 101 | 102 | Running tests 103 | ============= 104 | 105 | You can run tests either by executing:: 106 | 107 | $ python tests/test_qrbill.py 108 | 109 | or:: 110 | 111 | $ python setup.py test 112 | 113 | 114 | Sponsors 115 | ======== 116 | 117 | .. image:: https://seantis.ch/static/img/logo.svg 118 | :width: 150 119 | :target: https://seantis.ch/ 120 | -------------------------------------------------------------------------------- /qrbill/__init__.py: -------------------------------------------------------------------------------- 1 | from .bill import QRBill # NOQA 2 | -------------------------------------------------------------------------------- /qrbill/bill.py: -------------------------------------------------------------------------------- 1 | import re 2 | import warnings 3 | from decimal import Decimal 4 | from io import BytesIO 5 | from itertools import chain 6 | from pathlib import Path 7 | 8 | import qrcode 9 | import qrcode.image.svg 10 | import svgwrite 11 | from iso3166 import countries 12 | from stdnum import iban, iso11649 13 | from stdnum.ch import esr 14 | 15 | IBAN_ALLOWED_COUNTRIES = ['CH', 'LI'] 16 | QR_IID = {"start": 30000, "end": 31999} 17 | AMOUNT_REGEX = r'^\d{1,9}\.\d{2}$' 18 | 19 | MM_TO_UU = 3.543307 20 | BILL_HEIGHT = 106 # 105mm + 1mm for horizontal scissors to show up. 21 | RECEIPT_WIDTH = '62mm' 22 | PAYMENT_WIDTH = '148mm' 23 | MAX_CHARS_PAYMENT_LINE = 72 24 | MAX_CHARS_RECEIPT_LINE = 38 25 | A4 = ('210mm', '297mm') 26 | 27 | # Annex D: Multilingual headings 28 | LABELS = { 29 | 'Payment part': {'de': 'Zahlteil', 'fr': 'Section paiement', 'it': 'Sezione pagamento'}, 30 | 'Account / Payable to': { 31 | 'de': 'Konto / Zahlbar an', 32 | 'fr': 'Compte / Payable à', 33 | 'it': 'Conto / Pagabile a', 34 | }, 35 | 'Reference': {'de': 'Referenz', 'fr': 'Référence', 'it': 'Riferimento'}, 36 | 'Additional information': { 37 | 'de': 'Zusätzliche Informationen', 38 | 'fr': 'Informations supplémentaires', 39 | 'it': 'Informazioni supplementari', 40 | }, 41 | 'Currency': {'de': 'Währung', 'fr': 'Monnaie', 'it': 'Valuta'}, 42 | 'Amount': {'de': 'Betrag', 'fr': 'Montant', 'it': 'Importo'}, 43 | 'Receipt': {'de': 'Empfangsschein', 'fr': 'Récépissé', 'it': 'Ricevuta'}, 44 | 'Acceptance point': {'de': 'Annahmestelle', 'fr': 'Point de dépôt', 'it': 'Punto di accettazione'}, 45 | 'Separate before paying in': { 46 | 'de': 'Vor der Einzahlung abzutrennen', 47 | 'fr': 'A détacher avant le versement', 48 | 'it': 'Da staccare prima del versamento', 49 | }, 50 | 'Payable by': {'de': 'Zahlbar durch', 'fr': 'Payable par', 'it': 'Pagabile da'}, 51 | 'Payable by (name/address)': { 52 | 'de': 'Zahlbar durch (Name/Adresse)', 53 | 'fr': 'Payable par (nom/adresse)', 54 | 'it': 'Pagabile da (nome/indirizzo)', 55 | }, 56 | 'In favour of': {'de': 'Zugunsten', 'fr': 'En faveur de', 'it': 'A favore di'}, 57 | } 58 | 59 | SCISSORS_SVG_PATH = ( 60 | 'm 0.764814,4.283977 c 0.337358,0.143009 0.862476,-0.115279 0.775145,-0.523225 -0.145918,-0.497473 ' 61 | '-0.970289,-0.497475 -1.116209,-2e-6 -0.0636,0.23988 0.128719,0.447618 0.341064,0.523227 z m 3.875732,-1.917196 ' 62 | 'c 1.069702,0.434082 2.139405,0.868164 3.209107,1.302246 -0.295734,0.396158 -0.866482,0.368049 -1.293405,0.239509 ' 63 | '-0.876475,-0.260334 -1.71099,-0.639564 -2.563602,-0.966653 -0.132426,-0.04295 -0.265139,-0.124595 ' 64 | '-0.397393,-0.144327 -0.549814,0.22297 -1.09134,0.477143 -1.667719,0.62213 -0.07324,0.232838 0.150307,0.589809 ' 65 | '-0.07687,0.842328 -0.311347,0.532157 -1.113542,0.624698 -1.561273,0.213165 -0.384914,-0.301216 ' 66 | '-0.379442,-0.940948 7e-6,-1.245402 0.216628,-0.191603 0.506973,-0.286636 0.794095,-0.258382 0.496639,0.01219 ' 67 | '1.013014,-0.04849 1.453829,-0.289388 0.437126,-0.238777 0.07006,-0.726966 -0.300853,-0.765416 ' 68 | '-0.420775,-0.157424 -0.870816,-0.155853 -1.312747,-0.158623 -0.527075,-0.0016 -1.039244,-0.509731 ' 69 | '-0.904342,-1.051293 0.137956,-0.620793 0.952738,-0.891064 1.47649,-0.573851 0.371484,0.188118 ' 70 | '0.594679,0.675747 0.390321,1.062196 0.09829,0.262762 0.586716,0.204086 0.826177,0.378204 0.301582,0.119237 ' 71 | '0.600056,0.246109 0.899816,0.36981 0.89919,-0.349142 1.785653,-0.732692 2.698347,-1.045565 0.459138,-0.152333 ' 72 | '1.033472,-0.283325 1.442046,0.05643 0.217451,0.135635 -0.06954,0.160294 -0.174725,0.220936 -0.979101,0.397316 ' 73 | '-1.958202,0.794633 -2.937303,1.19195 z m -3.44165,-1.917196 c -0.338434,-0.14399 -0.861225,0.116943 ' 74 | '-0.775146,0.524517 0.143274,0.477916 0.915235,0.499056 1.10329,0.04328 0.09674,-0.247849 -0.09989,-0.490324 ' 75 | '-0.328144,-0.567796 z' 76 | ) 77 | 78 | 79 | class Address: 80 | @classmethod 81 | def create(cls, **kwargs): 82 | if kwargs.get('line1') or kwargs.get('line2'): 83 | for arg_name in ('street', 'house_num', 'pcode', 'city'): 84 | if kwargs.pop(arg_name, False): 85 | raise ValueError("When providing line1 or line2, you cannot provide %s" % arg_name) 86 | if not kwargs.get('line2'): 87 | raise ValueError("line2 is mandatory for combined address type.") 88 | return CombinedAddress(**kwargs) 89 | else: 90 | kwargs.pop('line1', None) 91 | kwargs.pop('line2', None) 92 | return StructuredAddress(**kwargs) 93 | 94 | @staticmethod 95 | def parse_country(country): 96 | country = (country or '').strip() 97 | # allow users to write the country as if used in an address in the local language 98 | if not country or country.lower() in ['schweiz', 'suisse', 'svizzera', 'svizra']: 99 | country = 'CH' 100 | if country.lower() in ['fürstentum liechtenstein']: 101 | country = 'LI' 102 | try: 103 | return countries.get(country).alpha2 104 | except KeyError: 105 | raise ValueError("The country code '%s' is not an ISO 3166 valid code" % country) 106 | 107 | @staticmethod 108 | def _split(line, max_chars): 109 | """ 110 | The line should be no more than `max_chars` chars, splitting on spaces 111 | (if possible). 112 | """ 113 | if '\n' in line: 114 | return list(chain(*[Address._split(li, max_chars) for li in line.split('\n')])) 115 | if len(line) <= max_chars: 116 | return [line] 117 | else: 118 | chunks = line.split(' ') 119 | lines = [] 120 | line2 = '' 121 | while chunks: 122 | if line2 and len(line2 + chunks[0]) + 1 > max_chars: 123 | lines.append(line2) 124 | line2 = '' 125 | line2 += (' ' if line2 else '') + chunks[0] 126 | chunks = chunks[1:] 127 | if line2: 128 | lines.append(line2) 129 | return lines 130 | 131 | 132 | class CombinedAddress(Address): 133 | """ 134 | Combined address 135 | (name, line1, line2, country) 136 | """ 137 | combined = True 138 | 139 | def __init__(self, *, name=None, line1=None, line2=None, country=None): 140 | self.name = (name or '').strip() 141 | self.line1 = (line1 or '').strip() 142 | if not (0 <= len(self.line1) <= 70): 143 | raise ValueError("An address line should have between 0 and 70 characters.") 144 | self.line2 = (line2 or '').strip() 145 | if not (0 <= len(self.line2) <= 70): 146 | raise ValueError("An address line should have between 0 and 70 characters.") 147 | self.country = self.parse_country(country) 148 | 149 | def data_list(self): 150 | # 'K': combined address 151 | return [ 152 | 'K', self.name.replace('\n', ' '), self.line1.replace('\n', ' '), 153 | self.line2.replace('\n', ' '), '', '', self.country 154 | ] 155 | 156 | def as_paragraph(self, max_chars=MAX_CHARS_PAYMENT_LINE): 157 | return chain(*(self._split(line, max_chars) for line in [self.name, self.line1, self.line2])) 158 | 159 | 160 | class StructuredAddress(Address): 161 | """ 162 | Structured address 163 | (name, street, house_num, pcode, city, country) 164 | """ 165 | combined = False 166 | 167 | def __init__(self, *, name=None, street=None, house_num=None, pcode=None, city=None, country=None): 168 | self.name = (name or '').strip() 169 | if not (1 <= len(self.name) <= 70): 170 | raise ValueError("An address name should have between 1 and 70 characters.") 171 | self.street = (street or '').strip() 172 | if len(self.street) > 70: 173 | raise ValueError("A street cannot have more than 70 characters.") 174 | self.house_num = (house_num or '').strip() 175 | if len(self.house_num) > 16: 176 | raise ValueError("A house number cannot have more than 16 characters.") 177 | self.pcode = (pcode or '').strip() 178 | if not self.pcode: 179 | raise ValueError("Postal code is mandatory") 180 | elif len(self.pcode) > 16: 181 | raise ValueError("A postal code cannot have more than 16 characters.") 182 | self.city = (city or '').strip() 183 | if not self.city: 184 | raise ValueError("City is mandatory") 185 | elif len(self.city) > 35: 186 | raise ValueError("A city cannot have more than 35 characters.") 187 | self.country = self.parse_country(country) 188 | 189 | def data_list(self): 190 | """Return address values as a list, appropriate for qr generation.""" 191 | # 'S': structured address 192 | return [ 193 | 'S', self.name.replace('\n', ' '), self.street.replace('\n', ' '), 194 | self.house_num, self.pcode, self.city, self.country 195 | ] 196 | 197 | def as_paragraph(self, max_chars=MAX_CHARS_PAYMENT_LINE): 198 | lines = [self.name, "%s-%s %s" % (self.country, self.pcode, self.city)] 199 | if self.street: 200 | if self.house_num: 201 | lines.insert(1, " ".join([self.street, self.house_num])) 202 | else: 203 | lines.insert(1, self.street) 204 | return chain(*(self._split(line, max_chars) for line in lines)) 205 | 206 | 207 | class QRBill: 208 | """This class represents a Swiss QR Bill.""" 209 | # Header fields 210 | qr_type = 'SPC' # Swiss Payments Code 211 | version = '0200' 212 | coding = 1 # Latin character set 213 | allowed_currencies = ('CHF', 'EUR') 214 | font_family = 'Arial,Helvetica' 215 | 216 | def __init__( 217 | self, account=None, creditor=None, final_creditor=None, amount=None, 218 | currency='CHF', debtor=None, ref_number=None, 219 | reference_number=None, extra_infos='', additional_information='', 220 | alt_procs=(), language='en', top_line=True, payment_line=True, font_factor=1): 221 | """ 222 | Arguments 223 | --------- 224 | account: str 225 | IBAN of the creditor (must start with 'CH' or 'LI') 226 | creditor: Address 227 | Address (combined or structured) of the creditor 228 | final_creditor: Address 229 | (for future use) 230 | amount: str 231 | currency: str 232 | two values allowed: 'CHF' and 'EUR' 233 | debtor: Address 234 | Address (combined or structured) of the debtor 235 | additional_information: str 236 | Additional information aimed for the bill recipient 237 | alt_procs: list of str (max 2) 238 | two additional fields for alternative payment schemes 239 | language: str 240 | language of the output (ISO, 2 letters): 'en', 'de', 'fr' or 'it' 241 | top_line: bool 242 | print a horizontal line at the top of the bill 243 | payment_line: bool 244 | print a vertical line between the receipt and the bill itself 245 | font_factor: integer 246 | a zoom factor for all texts in the bill 247 | """ 248 | # Account (IBAN) validation 249 | if not account: 250 | raise ValueError("The account parameter is mandatory") 251 | if not iban.is_valid(account): 252 | raise ValueError("Sorry, the IBAN is not valid") 253 | self.account = iban.validate(account) 254 | if self.account[:2] not in IBAN_ALLOWED_COUNTRIES: 255 | raise ValueError("IBAN must start with: %s" % ", ".join(IBAN_ALLOWED_COUNTRIES)) 256 | iban_iid = int(self.account[4:9]) 257 | if QR_IID["start"] <= iban_iid <= QR_IID["end"]: 258 | self.account_is_qriban = True 259 | else: 260 | self.account_is_qriban = False 261 | 262 | if amount is not None: 263 | if isinstance(amount, Decimal): 264 | amount = str(amount) 265 | elif not isinstance(amount, str): 266 | raise ValueError("Amount can only be specified as str or Decimal.") 267 | # remove commonly used thousands separators 268 | amount = amount.replace("'", "").strip() 269 | # people often don't add .00 for amounts without cents/rappen 270 | if "." not in amount: 271 | amount = amount + ".00" 272 | # support lazy people who write 12.1 instead of 12.10 273 | if amount[-2] == '.': 274 | amount = amount + '0' 275 | # strip leading zeros 276 | amount = amount.lstrip("0") 277 | # some people tend to strip the leading zero on amounts below 1 CHF/EUR 278 | # and with removing leading zeros, we would have removed the zero before 279 | # the decimal delimiter anyway 280 | if amount[0] == ".": 281 | amount = "0" + amount 282 | m = re.match(AMOUNT_REGEX, amount) 283 | if not m: 284 | raise ValueError( 285 | "If provided, the amount must match the pattern '###.##'" 286 | " and cannot be larger than 999'999'999.99" 287 | ) 288 | self.amount = amount 289 | if currency not in self.allowed_currencies: 290 | raise ValueError("Currency can only contain: %s" % ", ".join(self.allowed_currencies)) 291 | self.currency = currency 292 | if not creditor: 293 | raise ValueError("Creditor information is mandatory") 294 | try: 295 | self.creditor = Address.create(**creditor) 296 | except ValueError as err: 297 | raise ValueError("The creditor address is invalid: %s" % err) 298 | if final_creditor is not None: 299 | # The standard says ultimate creditor is reserved for future use. 300 | # The online validator does not properly validate QR-codes where 301 | # this is set, saying it must not (yet) be used. 302 | raise ValueError("final creditor is reserved for future use, must not be used") 303 | else: 304 | self.final_creditor = final_creditor 305 | if debtor is not None: 306 | try: 307 | self.debtor = Address.create(**debtor) 308 | except ValueError as err: 309 | raise ValueError("The debtor address is invalid: %s" % err) 310 | else: 311 | self.debtor = debtor 312 | 313 | if ref_number and reference_number: 314 | raise ValueError("You cannot provide values for both ref_number and reference_number") 315 | if ref_number: 316 | warnings.warn("ref_number is deprecated and replaced by reference_number") 317 | reference_number = ref_number 318 | if not reference_number: 319 | self.ref_type = 'NON' 320 | self.reference_number = None 321 | elif reference_number.strip()[:2].upper() == "RF": 322 | if iso11649.is_valid(reference_number): 323 | self.ref_type = 'SCOR' 324 | self.reference_number = iso11649.validate(reference_number) 325 | else: 326 | raise ValueError("The reference number is invalid") 327 | elif esr.is_valid(reference_number): 328 | self.ref_type = 'QRR' 329 | self.reference_number = esr.format(reference_number).replace(" ", "") 330 | else: 331 | raise ValueError("The reference number is invalid") 332 | 333 | # A QRR reference number must only be used with a QR-IBAN and 334 | # with a QR-IBAN, a QRR reference number must be used 335 | if self.account_is_qriban: 336 | if self.ref_type != 'QRR': 337 | raise ValueError("A QR-IBAN requires a QRR reference number") 338 | else: 339 | if self.ref_type == 'QRR': 340 | raise ValueError("A QRR reference number is only allowed for a QR-IBAN") 341 | 342 | if extra_infos and additional_information: 343 | raise ValueError("You cannot provide values for both extra_infos and additional_information") 344 | if extra_infos: 345 | warnings.warn("extra_infos is deprecated and replaced by additional_information") 346 | additional_information = extra_infos 347 | if additional_information and len(additional_information) > 140: 348 | raise ValueError("Additional information cannot contain more than 140 characters") 349 | self.additional_information = additional_information 350 | 351 | if len(alt_procs) > 2: 352 | raise ValueError("Only two lines allowed in alternative procedure parameters") 353 | if any(len(el) > 100 for el in alt_procs): 354 | raise ValueError("An alternative procedure line cannot be longer than 100 characters") 355 | self.alt_procs = list(alt_procs) 356 | 357 | # Meta-information 358 | if language not in ['en', 'de', 'fr', 'it']: 359 | raise ValueError("Language should be 'en', 'de', 'fr', or 'it'") 360 | self.language = language 361 | self.top_line = top_line 362 | self.payment_line = payment_line 363 | self.font_factor = font_factor 364 | 365 | @property 366 | def title_font_info(self): 367 | return {'font_size': 12 * self.font_factor, 'font_family': self.font_family, 'font_weight': 'bold'} 368 | 369 | @property 370 | def font_info(self): 371 | return {'font_size': 10 * self.font_factor, 'font_family': self.font_family} 372 | 373 | def head_font_info(self, part=None): 374 | return { 375 | 'font_size': (8 if part == 'receipt' else 9) * self.font_factor, 376 | 'font_family': self.font_family, 'font_weight': 'bold'} 377 | 378 | @property 379 | def proc_font_info(self): 380 | return {'font_size': 7 * self.font_factor, 'font_family': self.font_family} 381 | 382 | def qr_data(self): 383 | """ 384 | Return data to be encoded in the QR code in the standard text 385 | representation. 386 | """ 387 | values = [self.qr_type or '', self.version or '', self.coding or '', self.account or ''] 388 | values.extend(self.creditor.data_list()) 389 | values.extend(self.final_creditor.data_list() if self.final_creditor else [''] * 7) 390 | values.extend([self.amount or '', self.currency or '']) 391 | values.extend(self.debtor.data_list() if self.debtor else [''] * 7) 392 | values.extend([ 393 | self.ref_type or '', 394 | self.reference_number or '', 395 | replace_linebreaks(self.additional_information), 396 | ]) 397 | values.append('EPD') 398 | values.extend(self.alt_procs) 399 | return "\r\n".join([str(v) for v in values]) 400 | 401 | def qr_image(self): 402 | factory = qrcode.image.svg.SvgPathImage 403 | return qrcode.make( 404 | self.qr_data(), 405 | image_factory=factory, 406 | error_correction=qrcode.constants.ERROR_CORRECT_M, 407 | border=0, 408 | ) 409 | 410 | def draw_swiss_cross(self, dwg, grp, origin, size): 411 | """ 412 | draw swiss cross of size 20 in the middle of a square 413 | with upper left corner at origin and given size. 414 | """ 415 | group = grp.add(dwg.g(id="swiss-cross")) 416 | group.add( 417 | dwg.polygon(points=[ 418 | (18.3, 0.7), (1.6, 0.7), (0.7, 0.7), (0.7, 1.6), (0.7, 18.3), (0.7, 19.1), 419 | (1.6, 19.1), (18.3, 19.1), (19.1, 19.1), (19.1, 18.3), (19.1, 1.6), (19.1, 0.7) 420 | ], fill='black') 421 | ) 422 | group.add( 423 | dwg.rect(insert=(8.3, 4), size=(3.3, 11), fill='white') 424 | ) 425 | group.add( 426 | dwg.rect(insert=(4.4, 7.9), size=(11, 3.3), fill='white') 427 | ) 428 | group.add( 429 | dwg.polygon(points=[ 430 | (0.7, 1.6), (0.7, 18.3), (0.7, 19.1), (1.6, 19.1), (18.3, 19.1), (19.1, 19.1), 431 | (19.1, 18.3), (19.1, 1.6), (19.1, 0.7), (18.3, 0.7), (1.6, 0.7), (0.7, 0.7)], 432 | fill='none', stroke='white', stroke_width=1.4357, 433 | ) 434 | ) 435 | orig_x, orig_y = origin 436 | scale_factor = mm(7) / 19 437 | x = orig_x + (size / 2) - (10 * scale_factor) 438 | y = orig_y + (size / 2) - (10 * scale_factor) 439 | group.translate(tx=x, ty=y) 440 | group.scale(scale_factor) 441 | 442 | def draw_blank_rect(self, dwg, grp, x, y, width, height): 443 | """Draw a empty blank rect with corners (e.g. amount, debtor)""" 444 | # 0.75pt ~= 0.26mm 445 | stroke_info = {'stroke': 'black', 'stroke_width': '0.26mm', 'stroke_linecap': 'square'} 446 | rect_grp = grp.add(dwg.g()) 447 | rect_grp.add(dwg.line((x, y), (x, add_mm(y, mm(2))), **stroke_info)) 448 | rect_grp.add(dwg.line((x, y), (add_mm(x, mm(3)), y), **stroke_info)) 449 | rect_grp.add(dwg.line((x, add_mm(y, height)), (x, add_mm(y, height, mm(-2))), **stroke_info)) 450 | rect_grp.add(dwg.line((x, add_mm(y, height)), (add_mm(x, mm(3)), add_mm(y, height)), **stroke_info)) 451 | rect_grp.add(dwg.line((add_mm(x, width, mm(-3)), y), (add_mm(x, width), y), **stroke_info)) 452 | rect_grp.add(dwg.line((add_mm(x, width), y), (add_mm(x, width), add_mm(y, mm(2))), **stroke_info)) 453 | rect_grp.add(dwg.line( 454 | (add_mm(x, width, mm(-3)), add_mm(y, height)), (add_mm(x, width), add_mm(y, height)), 455 | **stroke_info 456 | )) 457 | rect_grp.add(dwg.line( 458 | (add_mm(x, width), add_mm(y, height)), (add_mm(x, width), add_mm(y, height, mm(-2))), 459 | **stroke_info 460 | )) 461 | 462 | def label(self, txt): 463 | return txt if self.language == 'en' else LABELS[txt][self.language] 464 | 465 | def as_svg(self, file_out, full_page=False): 466 | """ 467 | Format as SVG and write the result to file_out. 468 | file_out can be a str, a pathlib.Path or a file-like object open in text 469 | mode. 470 | """ 471 | if full_page: 472 | dwg = svgwrite.Drawing( 473 | size=A4, 474 | viewBox=('0 0 %f %f' % (mm(A4[0]), mm(A4[1]))), 475 | debug=False, 476 | ) 477 | else: 478 | dwg = svgwrite.Drawing( 479 | size=(A4[0], f'{BILL_HEIGHT}mm'), # A4 width, A6 height. 480 | viewBox=('0 0 %f %f' % (mm(A4[0]), mm(BILL_HEIGHT))), 481 | debug=False, 482 | ) 483 | dwg.add(dwg.rect(insert=(0, 0), size=('100%', '100%'), fill='white')) # Force white background 484 | 485 | bill_group = self.draw_bill(dwg, horiz_scissors=not full_page) 486 | if full_page: 487 | self.transform_to_full_page(dwg, bill_group) 488 | 489 | if isinstance(file_out, (str, Path)): 490 | dwg.saveas(file_out) 491 | else: 492 | dwg.write(file_out) 493 | 494 | def transform_to_full_page(self, dwg, bill): 495 | """Renders to a A4 page, adding bill in a group element. 496 | 497 | Adds a note about separating the bill as well. 498 | 499 | :param dwg: The svg drawing. 500 | :param bill: The svg group containing regular sized bill drawing. 501 | """ 502 | y_offset = mm(A4[1]) - mm(BILL_HEIGHT) 503 | bill.translate(tx=0, ty=y_offset) 504 | 505 | # add text snippet 506 | x_center = mm(A4[0]) / 2 507 | y_pos = y_offset - mm(1) 508 | 509 | dwg.add(dwg.text( 510 | self.label("Separate before paying in"), 511 | (x_center, y_pos), 512 | text_anchor="middle", 513 | font_style="italic", 514 | **self.font_info) 515 | ) 516 | 517 | def draw_bill(self, dwg, horiz_scissors=True): 518 | """Draw the bill in SVG format.""" 519 | margin = mm(5) 520 | payment_left = add_mm(RECEIPT_WIDTH, margin) 521 | payment_detail_left = add_mm(payment_left, mm(46 + 5)) 522 | above_padding = 1 # 1mm added for scissors display 523 | currency_top = mm(72 + above_padding) 524 | 525 | grp = dwg.add(dwg.g()) 526 | # Receipt 527 | y_pos = 15 + above_padding 528 | line_space = 3.5 529 | receipt_head_font = self.head_font_info(part='receipt') 530 | grp.add(dwg.text(self.label("Receipt"), (margin, mm(y_pos - 5)), **self.title_font_info)) 531 | grp.add(dwg.text(self.label("Account / Payable to"), (margin, mm(y_pos)), **receipt_head_font)) 532 | y_pos += line_space 533 | grp.add(dwg.text( 534 | iban.format(self.account), (margin, mm(y_pos)), **self.font_info 535 | )) 536 | y_pos += line_space 537 | for line_text in self.creditor.as_paragraph(max_chars=MAX_CHARS_RECEIPT_LINE): 538 | grp.add(dwg.text(line_text, (margin, mm(y_pos)), **self.font_info)) 539 | y_pos += line_space 540 | 541 | if self.reference_number: 542 | y_pos += 1 543 | grp.add(dwg.text(self.label("Reference"), (margin, mm(y_pos)), **receipt_head_font)) 544 | y_pos += line_space 545 | grp.add(dwg.text(format_ref_number(self), (margin, mm(y_pos)), **self.font_info)) 546 | y_pos += line_space 547 | 548 | y_pos += 1 549 | grp.add(dwg.text( 550 | self.label("Payable by") if self.debtor else self.label("Payable by (name/address)"), 551 | (margin, mm(y_pos)), **receipt_head_font 552 | )) 553 | y_pos += line_space 554 | if self.debtor: 555 | for line_text in self.debtor.as_paragraph(max_chars=MAX_CHARS_RECEIPT_LINE): 556 | grp.add(dwg.text(line_text, (margin, mm(y_pos)), **self.font_info)) 557 | y_pos += line_space 558 | else: 559 | self.draw_blank_rect( 560 | dwg, grp, x=margin, y=mm(y_pos), 561 | width=mm(52), height=mm(25) 562 | ) 563 | y_pos += 28 564 | 565 | grp.add(dwg.text(self.label("Currency"), (margin, currency_top), **receipt_head_font)) 566 | grp.add(dwg.text(self.label("Amount"), (add_mm(margin, mm(12)), currency_top), **receipt_head_font)) 567 | grp.add(dwg.text(self.currency, (margin, add_mm(currency_top, mm(5))), **self.font_info)) 568 | if self.amount: 569 | grp.add(dwg.text( 570 | format_amount(self.amount), 571 | (add_mm(margin, mm(12)), add_mm(currency_top, mm(5))), 572 | **self.font_info 573 | )) 574 | else: 575 | self.draw_blank_rect( 576 | dwg, grp, x=add_mm(margin, mm(25)), y=add_mm(currency_top, mm(-2)), 577 | width=mm(27), height=mm(11) 578 | ) 579 | 580 | # Right-aligned 581 | grp.add(dwg.text( 582 | self.label("Acceptance point"), (add_mm(RECEIPT_WIDTH, margin * -1), mm(86 + above_padding)), 583 | text_anchor='end', **receipt_head_font 584 | )) 585 | 586 | # Top separation line 587 | if self.top_line: 588 | grp.add(dwg.line( 589 | start=(0, mm(0.141 + above_padding)), 590 | end=(add_mm(RECEIPT_WIDTH, PAYMENT_WIDTH), mm(0.141 + above_padding)), 591 | stroke='black', stroke_dasharray='2 2', fill='none' 592 | )) 593 | if horiz_scissors: 594 | # Scissors on horizontal line 595 | path = dwg.path( 596 | d=SCISSORS_SVG_PATH, 597 | style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none", 598 | ) 599 | path.scale(1.9) 600 | path.translate(tx=24, ty=0) 601 | grp.add(path) 602 | 603 | # Separation line between receipt and payment parts 604 | if self.payment_line: 605 | grp.add(dwg.line( 606 | start=(mm(RECEIPT_WIDTH), mm(above_padding)), 607 | end=(mm(RECEIPT_WIDTH), mm(BILL_HEIGHT - above_padding)), 608 | stroke='black', stroke_dasharray='2 2', fill='none' 609 | )) 610 | # Scissors on vertical line 611 | path = dwg.path( 612 | d=SCISSORS_SVG_PATH, 613 | style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none", 614 | ) 615 | path.scale(1.9) 616 | path.translate(tx=118, ty=40) 617 | path.rotate(90) 618 | grp.add(path) 619 | 620 | # Payment part 621 | payment_head_font = self.head_font_info(part='payment') 622 | grp.add(dwg.text(self.label("Payment part"), (payment_left, mm(10 + above_padding)), **self.title_font_info)) 623 | 624 | # Get QR code SVG from qrcode lib, read it and redraw path in svgwrite drawing. 625 | buff = BytesIO() 626 | im = self.qr_image() 627 | im.save(buff) 628 | m = re.search(r']*>', buff.getvalue().decode()) 629 | if not m: 630 | raise Exception("Unable to extract path data from the QR code SVG image") 631 | m = re.search(r' d=\"([^\"]*)\"', m.group()) 632 | if not m: 633 | raise Exception("Unable to extract path d attributes from the SVG QR code source") 634 | path_data = m.groups()[0] 635 | path = dwg.path( 636 | d=path_data, 637 | style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none", 638 | ) 639 | 640 | # Limit scaling to max dimension (specs says 46mm, keep a bit of margin) 641 | scale_factor = mm(45.8) / im.width 642 | 643 | qr_left = payment_left 644 | qr_top = 60 + above_padding 645 | path.translate(tx=qr_left, ty=qr_top) 646 | path.scale(scale_factor) 647 | grp.add(path) 648 | 649 | self.draw_swiss_cross(dwg, grp, (payment_left, qr_top), im.width * scale_factor) 650 | 651 | grp.add(dwg.text(self.label("Currency"), (payment_left, currency_top), **payment_head_font)) 652 | grp.add(dwg.text(self.label("Amount"), (add_mm(payment_left, mm(12)), currency_top), **payment_head_font)) 653 | grp.add(dwg.text(self.currency, (payment_left, add_mm(currency_top, mm(5))), **self.font_info)) 654 | if self.amount: 655 | grp.add(dwg.text( 656 | format_amount(self.amount), 657 | (add_mm(payment_left, mm(12)), add_mm(currency_top, mm(5))), 658 | **self.font_info 659 | )) 660 | else: 661 | self.draw_blank_rect( 662 | dwg, grp, x=add_mm(RECEIPT_WIDTH, margin, mm(12)), y=add_mm(currency_top, mm(3)), 663 | width=mm(40), height=mm(15) 664 | ) 665 | 666 | # Right side of the bill 667 | y_pos = 10 + above_padding 668 | line_space = 3.5 669 | 670 | def add_header(text, first=False): 671 | nonlocal dwg, grp, payment_detail_left, y_pos 672 | if not first: 673 | y_pos += 3 674 | grp.add(dwg.text(text, (payment_detail_left, mm(y_pos)), **payment_head_font)) 675 | y_pos += line_space 676 | 677 | add_header(self.label("Account / Payable to"), first=True) 678 | grp.add(dwg.text( 679 | iban.format(self.account), (payment_detail_left, mm(y_pos)), **self.font_info 680 | )) 681 | y_pos += line_space 682 | 683 | for line_text in self.creditor.as_paragraph(): 684 | grp.add(dwg.text(line_text, (payment_detail_left, mm(y_pos)), **self.font_info)) 685 | y_pos += line_space 686 | 687 | if self.reference_number: 688 | add_header(self.label("Reference")) 689 | grp.add(dwg.text( 690 | format_ref_number(self), (payment_detail_left, mm(y_pos)), **self.font_info 691 | )) 692 | y_pos += line_space 693 | 694 | if self.additional_information: 695 | add_header(self.label("Additional information")) 696 | if '//' in self.additional_information: 697 | additional_information = self.additional_information.split('//') 698 | additional_information[1] = '//' + additional_information[1] 699 | else: 700 | additional_information = [self.additional_information] 701 | # TODO: handle line breaks for long infos (mandatory 5mm margin) 702 | for info in wrap_infos(additional_information): 703 | grp.add(dwg.text(info, (payment_detail_left, mm(y_pos)), **self.font_info)) 704 | y_pos += line_space 705 | 706 | if self.debtor: 707 | add_header(self.label("Payable by")) 708 | for line_text in self.debtor.as_paragraph(): 709 | grp.add(dwg.text(line_text, (payment_detail_left, mm(y_pos)), **self.font_info)) 710 | y_pos += line_space 711 | else: 712 | add_header(self.label("Payable by (name/address)")) 713 | # The specs recomment at least 2.5 x 6.5 cm 714 | self.draw_blank_rect( 715 | dwg, grp, x=payment_detail_left, y=mm(y_pos), 716 | width=mm(65), height=mm(25) 717 | ) 718 | y_pos += 28 719 | 720 | if self.final_creditor: 721 | add_header(self.label("In favor of")) 722 | for line_text in self.final_creditor.as_paragraph(): 723 | grp.add(dwg.text(line_text, (payment_detail_left, mm(y_pos)), **self.font_info)) 724 | y_pos += line_space 725 | 726 | # Bottom section 727 | y_pos = mm(94) 728 | for alt_proc_line in self.alt_procs: 729 | grp.add(dwg.text( 730 | alt_proc_line, (payment_left, y_pos), **self.proc_font_info 731 | )) 732 | y_pos += mm(2.2) 733 | return grp 734 | 735 | 736 | def add_mm(*mms): 737 | """Utility to allow additions of '23mm'-type strings.""" 738 | return round( 739 | sum( 740 | mm(float(m[:-2])) if isinstance(m, str) else m for m in mms 741 | ), 742 | 5 743 | ) 744 | 745 | 746 | def mm(val): 747 | """Convert val (as mm, either number of '12mm' str) into user units.""" 748 | try: 749 | val = float(val.rstrip('mm')) 750 | except AttributeError: 751 | pass 752 | return round(val * MM_TO_UU, 5) 753 | 754 | 755 | def format_ref_number(bill): 756 | if not bill.reference_number: 757 | return '' 758 | num = bill.reference_number 759 | if bill.ref_type == "QRR": 760 | return esr.format(num) 761 | elif bill.ref_type == "SCOR": 762 | return iso11649.format(num) 763 | else: 764 | return num 765 | 766 | 767 | def format_amount(amount_): 768 | return '{:,.2f}'.format(float(amount_)).replace(",", " ") 769 | 770 | 771 | def wrap_infos(infos): 772 | for line in infos: 773 | for text in line.splitlines(): 774 | while text: 775 | yield text[:MAX_CHARS_PAYMENT_LINE] 776 | text = text[MAX_CHARS_PAYMENT_LINE:] 777 | 778 | 779 | def replace_linebreaks(text): 780 | text = text or '' 781 | return ' '.join(text.splitlines()) 782 | -------------------------------------------------------------------------------- /scripts/qrbill: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import argparse 4 | import sys 5 | import warnings 6 | from datetime import datetime 7 | from importlib import metadata 8 | 9 | from qrbill import QRBill 10 | 11 | 12 | def clean_nl(value): 13 | """A '\n' in the command line will be escaped to '\\n'.""" 14 | return value.replace('\\n', '\n') if isinstance(value, str) else value 15 | 16 | 17 | def run(): 18 | parser = argparse.ArgumentParser() 19 | parser.add_argument('--version', action='version', version='%(prog)s ' + metadata.version('qrbill')) 20 | parser.add_argument('--output', 21 | help='output file') 22 | parser.add_argument('-t', '--text', 23 | help='print the bill data as text on the console ' 24 | 'and exit if --output is not provided', 25 | action='store_true') 26 | parser.add_argument('--account', required=True, 27 | help='creditor IBAN account number') 28 | parser.add_argument('--creditor-name', required=True, 29 | help='creditor name') 30 | parser.add_argument('--creditor-line1', 31 | help='creditor address line 1') 32 | parser.add_argument('--creditor-line2', 33 | help='creditor address line 2') 34 | parser.add_argument('--creditor-street', 35 | help='creditor street') 36 | parser.add_argument('--creditor-housenumber', 37 | help='creditor house number') 38 | parser.add_argument('--creditor-postalcode', 39 | help='creditor postal code') 40 | parser.add_argument('--creditor-city', 41 | help='creditor city') 42 | parser.add_argument('--creditor-country', default='CH', 43 | help='creditor country') 44 | parser.add_argument('--amount', 45 | help='amount of payment') 46 | # only CHF and EUR are acceptable 47 | # see https://www.paymentstandards.ch/dam/downloads/ig-qr-bill-en.pdf 48 | # chapter 4.3.3 elemet Ccy 49 | parser.add_argument('--currency', default='CHF', choices=['CHF', 'EUR'], 50 | help='currency of payment') 51 | parser.add_argument('--debtor-name', 52 | help='debtor name') 53 | parser.add_argument('--debtor-line1', 54 | help='debtor address line 1') 55 | parser.add_argument('--debtor-line2', 56 | help='debtor address line 1') 57 | parser.add_argument('--debtor-street', 58 | help='debtor street') 59 | parser.add_argument('--debtor-housenumber', 60 | help='debtor house number') 61 | parser.add_argument('--debtor-postalcode', 62 | help='debtor postal code') 63 | parser.add_argument('--debtor-city', 64 | help='debtor city') 65 | parser.add_argument('--debtor-country', default='CH', 66 | help='debtor country') 67 | parser.add_argument('--reference-number', 68 | help='reference number') 69 | # extra-infos kept for backwards compatibility 70 | parser.add_argument('--additional-information', '--extra-infos', 71 | help='payment purpose') 72 | parser.add_argument('--alt-procs', 73 | nargs='*', 74 | help='alternative payment parameters (2 lines max)', 75 | default=()) 76 | # see https://www.paymentstandards.ch/dam/downloads/ig-qr-bill-en.pdf 77 | # annex D table 16 78 | parser.add_argument('--language', default='en', choices=['en', 'de', 'fr', 'it'], 79 | help='language') 80 | parser.add_argument('--full-page', default=False, action='store_true', 81 | help='Print to full A4 size page') 82 | parser.add_argument('--no-top-line', dest="top_line", default=True, action='store_false', 83 | help='Do not print top separation line') 84 | parser.add_argument('--no-payment-line', dest="payment_line", default=True, action='store_false', 85 | help='Do not print vertical separation line between receipt and payment parts') 86 | parser.add_argument('--font-factor', dest="font_factor", default=1.0, type=float, 87 | help='Font factor to provide a zoom for all texts on the bill') 88 | 89 | args = parser.parse_args() 90 | creditor = { 91 | 'name': clean_nl(args.creditor_name), 92 | 'line1': clean_nl(args.creditor_line1), 93 | 'line2': clean_nl(args.creditor_line2), 94 | 'street': clean_nl(args.creditor_street), 95 | 'house_num': args.creditor_housenumber, 96 | 'pcode': args.creditor_postalcode, 97 | 'city': args.creditor_city, 98 | 'country': args.creditor_country, 99 | } if args.creditor_name else None 100 | debtor = { 101 | 'name': clean_nl(args.debtor_name), 102 | 'line1': clean_nl(args.debtor_line1), 103 | 'line2': clean_nl(args.debtor_line2), 104 | 'street': clean_nl(args.debtor_street), 105 | 'house_num': args.debtor_housenumber, 106 | 'pcode': args.debtor_postalcode, 107 | 'city': args.debtor_city, 108 | 'country': args.debtor_country, 109 | } if args.debtor_name else None 110 | 111 | if args.output and not args.output.endswith('.svg'): 112 | warnings.warn("Warning: The output file name should end with .svg") 113 | 114 | try: 115 | bill = QRBill( 116 | account=args.account, 117 | creditor=creditor, 118 | amount=args.amount, 119 | currency=args.currency, 120 | debtor=debtor, 121 | reference_number=args.reference_number, 122 | additional_information=args.additional_information, 123 | alt_procs=args.alt_procs, 124 | language=args.language, 125 | top_line=args.top_line, 126 | payment_line=args.payment_line, 127 | font_factor=args.font_factor, 128 | ) 129 | except ValueError as err: 130 | sys.exit("Error: %s" % err) 131 | 132 | if args.text: 133 | # print as text: 134 | print(bill.qr_data()) 135 | # exit, unless an output file is explicitly required: 136 | if not args.output: 137 | return 138 | 139 | if args.output: 140 | out_path = args.output 141 | else: 142 | out_path = "{}-{}.svg".format( 143 | args.account.replace(' ', ''), 144 | datetime.now().strftime("%Y-%m-%d_%H%M%S") 145 | ) 146 | bill.as_svg(out_path, full_page=args.full_page) 147 | 148 | if __name__ == '__main__': 149 | run() 150 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = qrbill 3 | version = 1.1.0 4 | description = A library to generate Swiss QR-bill payment slips 5 | long_description = file: README.rst, CHANGELOG.rst 6 | license = MIT 7 | author = Claude Paroz 8 | author_email = claude@2xlibre.net 9 | url = https://github.com/claudep/swiss-qr-bill/ 10 | classifiers = 11 | Development Status :: 5 - Production/Stable 12 | License :: OSI Approved :: MIT License 13 | Programming Language :: Python :: 3 14 | 15 | [options] 16 | packages = find: 17 | scripts = 18 | scripts/qrbill 19 | install_requires = 20 | iso3166 21 | python-stdnum>=1.13 22 | qrcode 23 | svgwrite 24 | test_suite = tests 25 | 26 | [flake8] 27 | max-line-length = 119 28 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | 5 | setup() 6 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/claudep/swiss-qr-bill/00dcd5dc984e00522148d079d4d27ac260c0d6f0/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_qrbill.py: -------------------------------------------------------------------------------- 1 | import re 2 | import subprocess 3 | import sys 4 | import tempfile 5 | import unittest 6 | from decimal import Decimal 7 | from io import StringIO 8 | 9 | from qrbill import QRBill 10 | from qrbill.bill import Address, format_ref_number, format_amount, mm 11 | 12 | 13 | class AddressTests(unittest.TestCase): 14 | def test_name_limit(self): 15 | err_msg = "An address name should have between 1 and 70 characters." 16 | defaults = {'pcode': '1234', 'city': 'Somewhere'} 17 | with self.assertRaisesRegex(ValueError, err_msg): 18 | Address.create(name='', **defaults) 19 | with self.assertRaisesRegex(ValueError, err_msg): 20 | Address.create(name='a' * 71, **defaults) 21 | Address.create(name='a', **defaults) 22 | # Spaces are stripped 23 | addr = Address.create(name=' {} '.format('a' * 70), **defaults) 24 | self.assertEqual(addr.name, 'a' * 70) 25 | 26 | def test_combined(self): 27 | err_msg = "line2 is mandatory for combined address type." 28 | with self.assertRaisesRegex(ValueError, err_msg): 29 | Address.create(name='Me', line1='Something') 30 | err_msg = "An address line should have between 0 and 70 characters." 31 | with self.assertRaisesRegex(ValueError, err_msg): 32 | Address.create(name='Me', line1='a' * 71, line2='b') 33 | with self.assertRaisesRegex(ValueError, err_msg): 34 | Address.create(name='Me', line1='a', line2='b' * 71) 35 | 36 | def test_split_lines(self): 37 | self.assertEqual( 38 | Address._split('Short line', 30), 39 | ['Short line'] 40 | ) 41 | self.assertEqual( 42 | Address._split('A very long line that will not fit in available space', 20), 43 | ['A very long line', 'that will not fit in', 'available space'] 44 | ) 45 | self.assertEqual( 46 | Address._split('AVeryLongLineWithoutSpacesAndCannotBeSplit', 20), 47 | ['AVeryLongLineWithoutSpacesAndCannotBeSplit'] 48 | ) 49 | 50 | def test_newlines(self): 51 | # Combined address 52 | addr = Address.create( 53 | name='A long name line with\nforced newline position', 54 | line1='A long street line with\nforced newline position', 55 | line2='Second line', 56 | ) 57 | self.assertEqual( 58 | addr.data_list(), 59 | [ 60 | 'K', 'A long name line with forced newline position', 61 | 'A long street line with forced newline position', 62 | 'Second line', '', '', 'CH', 63 | ] 64 | ) 65 | self.assertEqual( 66 | list(addr.as_paragraph()), 67 | [ 68 | 'A long name line with', 'forced newline position', 69 | 'A long street line with', 'forced newline position', 'Second line', 70 | ] 71 | ) 72 | # Structured address 73 | addr = Address.create( 74 | name='A long name line with\nforced newline position', 75 | street='A long street line with\nforced newline position', 76 | pcode='2735', 77 | city='Bévilard', 78 | ) 79 | self.assertEqual( 80 | addr.data_list(), 81 | [ 82 | 'S', 'A long name line with forced newline position', 83 | 'A long street line with forced newline position', '', 84 | '2735', 'Bévilard', 'CH', 85 | ] 86 | ) 87 | self.assertEqual( 88 | list(addr.as_paragraph()), 89 | [ 90 | 'A long name line with', 'forced newline position', 91 | 'A long street line with', 'forced newline position', 92 | 'CH-2735 Bévilard', 93 | ] 94 | ) 95 | 96 | 97 | class QRBillTests(unittest.TestCase): 98 | def _produce_svg(self, bill, **kwargs): 99 | buff = StringIO() 100 | bill.as_svg(buff, **kwargs) 101 | return buff.getvalue() 102 | 103 | def test_mandatory_fields(self): 104 | with self.assertRaisesRegex(ValueError, "The account parameter is mandatory"): 105 | QRBill() 106 | with self.assertRaisesRegex(ValueError, "Creditor information is mandatory"): 107 | QRBill(account="CH5380005000010283664") 108 | 109 | def test_account(self): 110 | with self.assertRaisesRegex(ValueError, "Sorry, the IBAN is not valid"): 111 | bill = QRBill( 112 | account="CH53800050000102836", 113 | creditor={ 114 | 'name': 'Jane', 'pcode': '1000', 'city': 'Lausanne', 'country': 'CH', 115 | }, 116 | ) 117 | with self.assertRaisesRegex(ValueError, "Sorry, the IBAN is not valid"): 118 | bill = QRBill( 119 | account="CH5380005000010288664", 120 | creditor={ 121 | 'name': 'Jane', 'pcode': '1000', 'city': 'Lausanne', 'country': 'CH', 122 | }, 123 | ) 124 | with self.assertRaisesRegex(ValueError, "IBAN must start with: CH, LI"): 125 | bill = QRBill( 126 | account="DE 89 37040044 0532013000", 127 | creditor={ 128 | 'name': 'Jane', 'pcode': '1000', 'city': 'Lausanne', 'country': 'CH', 129 | }, 130 | ) 131 | # Spaces are auto-stripped 132 | bill = QRBill( 133 | account="CH 53 8000 5000 0102 83664", 134 | creditor={ 135 | 'name': 'Jane', 'pcode': '1000', 'city': 'Lausanne', 'country': 'CH', 136 | }, 137 | ) 138 | self.assertEqual(bill.account, "CH5380005000010283664") 139 | 140 | def test_country(self): 141 | bill_data = { 142 | 'account': 'CH5380005000010283664', 143 | } 144 | for creditor in [ 145 | { 146 | 'name': 'Jane', 147 | 'pcode': '1000', 148 | 'city': 'Lausanne', 149 | }, 150 | { 151 | 'name': 'Jane', 152 | 'line1': 'rue de foo 123', 153 | 'line2': '1000 Lausanne', 154 | }, 155 | ]: 156 | bill_data["creditor"] = creditor 157 | # Switzerland - German/French/Italian/Romansh/English/code 158 | for country_name in ('Schweiz', 'Suisse', 'Svizzera', 'Svizra', 'Switzerland', 'CH'): 159 | bill_data['creditor']['country'] = country_name 160 | bill = QRBill(**bill_data) 161 | self.assertEqual(bill.creditor.country, 'CH') 162 | 163 | # Liechtenstein - short and long names/code 164 | for country_name in ('Liechtenstein', 'Fürstentum Liechtenstein', 'LI'): 165 | bill_data['creditor']['country'] = country_name 166 | bill = QRBill(**bill_data) 167 | self.assertEqual(bill.creditor.country, 'LI') 168 | 169 | with self.assertRaisesRegex(ValueError, "The country code 'XY' is not an ISO 3166 valid code"): 170 | bill_data['creditor']['country'] = 'XY' 171 | bill = QRBill(**bill_data) 172 | 173 | def test_currency(self): 174 | with self.assertRaisesRegex(ValueError, "Currency can only contain: CHF, EUR"): 175 | bill = QRBill( 176 | account="CH 53 8000 5000 0102 83664", 177 | currency="USD", 178 | creditor={ 179 | 'name': 'Jane', 'pcode': '1000', 'city': 'Lausanne', 'country': 'CH', 180 | }, 181 | ) 182 | bill = QRBill( 183 | account="CH 53 8000 5000 0102 83664", 184 | currency="CHF", 185 | creditor={ 186 | 'name': 'Jane', 'pcode': '1000', 'city': 'Lausanne', 'country': 'CH', 187 | }, 188 | ) 189 | self.assertEqual(bill.currency, "CHF") 190 | bill = QRBill( 191 | account="CH 53 8000 5000 0102 83664", 192 | currency="EUR", 193 | creditor={ 194 | 'name': 'Jane', 'pcode': '1000', 'city': 'Lausanne', 'country': 'CH', 195 | }, 196 | ) 197 | self.assertEqual(bill.currency, "EUR") 198 | 199 | def test_amount(self): 200 | amount_err = ( 201 | "If provided, the amount must match the pattern '###.##' and cannot " 202 | "be larger than 999'999'999.99" 203 | ) 204 | type_err = "Amount can only be specified as str or Decimal." 205 | unvalid_inputs = [ 206 | ("1234567890.00", amount_err), # Too high value 207 | ("1.001", amount_err), # More than 2 decimals 208 | (Decimal("1.001"), amount_err), # Same but with Decimal type 209 | ("CHF800", amount_err), # Currency included 210 | (1.35, type_err), # Float are not accepted (rounding issues) 211 | ] 212 | for value, err in unvalid_inputs: 213 | with self.assertRaisesRegex(ValueError, err): 214 | bill = QRBill( 215 | account="CH 53 8000 5000 0102 83664", 216 | amount=value, 217 | creditor={ 218 | 'name': 'Jane', 'pcode': '1000', 'city': 'Lausanne', 'country': 'CH', 219 | }, 220 | ) 221 | 222 | valid_inputs = [ 223 | (".5", "0.50", "0.50"), 224 | ("42", "42.00", "42.00"), 225 | ("001'800", "1800.00", "1 800.00"), 226 | (" 3.45 ", "3.45", "3.45"), 227 | ("9'999'999.4 ", "9999999.40", "9 999 999.40"), 228 | (Decimal("35.9"), "35.90", "35.90"), 229 | ] 230 | for value, expected, printed in valid_inputs: 231 | bill = QRBill( 232 | account="CH 53 8000 5000 0102 83664", 233 | amount=value, 234 | creditor={ 235 | 'name': 'Jane', 'pcode': '1000', 'city': 'Lausanne', 'country': 'CH', 236 | }, 237 | ) 238 | self.assertEqual(bill.amount, expected) 239 | self.assertEqual(format_amount(bill.amount), printed) 240 | 241 | def test_additionnal_info_break(self): 242 | """ 243 | Line breaks in additional_information are converted to space in QR data 244 | (but still respected on info display) 245 | """ 246 | bill = QRBill( 247 | account="CH 53 8000 5000 0102 83664", 248 | creditor={ 249 | 'name': 'Jane', 'pcode': '1000', 'city': 'Lausanne', 250 | }, 251 | additional_information="Hello\nLine break", 252 | ) 253 | self.assertEqual( 254 | bill.qr_data(), 255 | 'SPC\r\n0200\r\n1\r\nCH5380005000010283664\r\nS\r\nJane\r\n\r\n\r\n' 256 | '1000\r\nLausanne\r\nCH\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\nCHF\r\n' 257 | '\r\n\r\n\r\n\r\n\r\n\r\n\r\nNON\r\n\r\nHello Line break\r\nEPD' 258 | ) 259 | with open("test1.svg", 'w') as fh: 260 | bill.as_svg(fh.name) 261 | 262 | def test_minimal_data(self): 263 | bill = QRBill( 264 | account="CH 53 8000 5000 0102 83664", 265 | creditor={ 266 | 'name': 'Jane', 'pcode': '1000', 'city': 'Lausanne', 267 | }, 268 | ) 269 | self.assertEqual( 270 | bill.qr_data(), 271 | 'SPC\r\n0200\r\n1\r\nCH5380005000010283664\r\nS\r\nJane\r\n\r\n\r\n' 272 | '1000\r\nLausanne\r\nCH\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\nCHF\r\n' 273 | '\r\n\r\n\r\n\r\n\r\n\r\n\r\nNON\r\n\r\n\r\nEPD' 274 | ) 275 | with tempfile.NamedTemporaryFile(suffix='.svg') as fh: 276 | bill.as_svg(fh.name) 277 | content = fh.read().decode() 278 | self.assertTrue(content.startswith('')) 279 | 280 | def test_ultimate_creditor(self): 281 | bill_data = { 282 | 'account': "CH 53 8000 5000 0102 83664", 283 | 'creditor': { 284 | 'name': 'Jane', 'pcode': '1000', 'city': 'Lausanne', 285 | }, 286 | 'final_creditor': { 287 | 'name': 'Jane', 'pcode': '1000', 'city': 'Lausanne', 288 | }, 289 | } 290 | with self.assertRaisesRegex(ValueError, "final creditor is reserved for future use, must not be used"): 291 | QRBill(**bill_data) 292 | 293 | def test_spec_example1(self): 294 | bill = QRBill( 295 | account='CH4431999123000889012', 296 | creditor={ 297 | 'name': 'Robert Schneider AG', 298 | 'street': 'Rue du Lac', 299 | 'house_num': '1268', 300 | 'pcode': '2501', 301 | 'city': 'Biel', 302 | 'country': 'CH', 303 | }, 304 | amount='1949.7', 305 | currency='CHF', 306 | debtor={ 307 | 'name': 'Pia-Maria Rutschmann-Schnyder', 308 | 'street': 'Grosse Marktgasse', 309 | 'house_num': '28', 310 | 'pcode': '9400', 311 | 'city': 'Rorschach', 312 | 'country': 'CH', 313 | }, 314 | reference_number='210000000003139471430009017', 315 | additional_information=( 316 | 'Order of 15.09.2019//S1/01/20170309/11/10201409/20/1400' 317 | '0000/22/36958/30/CH106017086/40/1020/41/3010' 318 | ) 319 | ) 320 | ''' 321 | AP1 – Parameters UV1;1.1;1278564;1A-2F-43-AC-9B-33-21-B0-CC-D4- 322 | 28-56;TCXVMKC22;2019-02-10T15: 12:39; 2019-02- 323 | 10T15:18:16¶ 324 | AP2 – Parameters XY2;2a-2.2r;_R1-CH2_ConradCH-2074-1_33 325 | 50_2019-03-13T10:23:47_16,99_0,00_0,00_ 326 | 0,00_0,00_+8FADt/DQ=_1== 327 | ''' 328 | self.assertEqual( 329 | bill.qr_data(), 330 | 'SPC\r\n0200\r\n1\r\nCH4431999123000889012\r\nS\r\nRobert Schneider AG\r\n' 331 | 'Rue du Lac\r\n1268\r\n2501\r\nBiel\r\nCH\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n' 332 | '1949.70\r\nCHF\r\nS\r\nPia-Maria Rutschmann-Schnyder\r\nGrosse Marktgasse\r\n' 333 | '28\r\n9400\r\nRorschach\r\nCH\r\nQRR\r\n210000000003139471430009017\r\n' 334 | 'Order of 15.09.2019//S1/01/20170309/11/10201409/20/14000000/22/36958/30/CH106017086' 335 | '/40/1020/41/3010\r\nEPD' 336 | ) 337 | with tempfile.NamedTemporaryFile(suffix='.svg') as fh: 338 | bill.as_svg(fh.name) 339 | content = strip_svg_path(fh.read().decode()) 340 | self.assertTrue(content.startswith('')) 341 | font9 = 'font-family="Arial,Helvetica" font-size="9" font-weight="bold"' 342 | font10 = 'font-family="Arial,Helvetica" font-size="10"' 343 | # Test the Payable by section: 344 | expected = ( 345 | 'Payable by' 346 | 'Pia-Maria Rutschmann-Schnyder' 347 | 'Grosse Marktgasse 28' 348 | 'CH-9400 Rorschach'.format( 349 | font9=font9, font10=font10, x='418.11023', 350 | y1=mm(58.5), y2=mm(62), y3=mm(65.5), y4=mm(69), 351 | ) 352 | ) 353 | self.assertIn(expected, content) 354 | # IBAN formatted 355 | self.assertIn( 356 | 'CH44 3199 9123 0008 8901 2'.format( 357 | font10=font10, x=mm(5), y=mm(19.5), 358 | ), 359 | content 360 | ) 361 | # amount formatted (receipt part) 362 | self.assertIn( 363 | 'CHF'.format( 364 | font10=font10, x=mm(5), y=mm(78), 365 | ), 366 | content 367 | ) 368 | self.assertIn( 369 | '1 949.70'.format( 370 | font10=font10, x=mm(17), y=mm(78), 371 | ), 372 | content 373 | ) 374 | # amount formatted (payment part) 375 | self.assertIn( 376 | 'CHF'.format( 377 | font10=font10, x=mm(67), y=mm(78), 378 | ), 379 | content 380 | ) 381 | self.assertIn( 382 | '1 949.70'.format( 383 | font10=font10, x=mm(79), y=mm(78), 384 | ), 385 | content 386 | ) 387 | 388 | def test_reference(self): 389 | min_data = { 390 | 'account': "CH 53 8000 5000 0102 83664", 391 | 'creditor': { 392 | 'name': 'Jane', 'pcode': '1000', 'city': 'Lausanne', 393 | }, 394 | } 395 | bill = QRBill(**min_data) 396 | self.assertEqual(bill.ref_type, 'NON') 397 | self.assertEqual(format_ref_number(bill), '') 398 | 399 | bill = QRBill(**min_data, reference_number='RF18539007547034') 400 | self.assertEqual(bill.ref_type, 'SCOR') 401 | self.assertEqual(format_ref_number(bill), 'RF18 5390 0754 7034') 402 | with self.assertRaisesRegex(ValueError, "The reference number is invalid"): 403 | bill = QRBill(**min_data, reference_number='RF19539007547034') 404 | with self.assertRaisesRegex(ValueError, "A QRR reference number is only allowed for a QR-IBAN"): 405 | bill = QRBill(**min_data, reference_number='18 78583') 406 | 407 | min_data = { 408 | 'account': "CH 44 3199 9123 0008 89012", 409 | 'creditor': { 410 | 'name': 'Jane', 'pcode': '1000', 'city': 'Lausanne', 411 | }, 412 | } 413 | bill = QRBill(**min_data, reference_number='210000000003139471430009017') 414 | self.assertEqual(bill.ref_type, 'QRR') 415 | self.assertEqual(format_ref_number(bill), '21 00000 00003 13947 14300 09017') 416 | 417 | # check leading zeros 418 | bill = QRBill(**min_data, reference_number='18 78583') 419 | self.assertEqual(bill.ref_type, 'QRR') 420 | self.assertEqual(format_ref_number(bill), '00 00000 00000 00000 00018 78583') 421 | 422 | # invalid QRR 423 | with self.assertRaisesRegex(ValueError, "The reference number is invalid"): 424 | bill = QRBill(**min_data, reference_number='18539007547034') 425 | with self.assertRaisesRegex(ValueError, "The reference number is invalid"): 426 | bill = QRBill(**min_data, reference_number='ref-number') 427 | with self.assertRaisesRegex(ValueError, "A QR-IBAN requires a QRR reference number"): 428 | bill = QRBill(**min_data, reference_number='RF18539007547034') 429 | 430 | def test_alt_procs(self): 431 | min_data = { 432 | 'account': "CH 53 8000 5000 0102 83664", 433 | 'creditor': { 434 | 'name': 'Jane', 'pcode': '1000', 'city': 'Lausanne', 435 | }, 436 | } 437 | err1 = "An alternative procedure line cannot be longer than 100 characters" 438 | with self.assertRaisesRegex(ValueError, err1): 439 | bill = QRBill(**min_data, alt_procs=['x' * 101]) 440 | err2 = "Only two lines allowed in alternative procedure parameters" 441 | with self.assertRaisesRegex(ValueError, err2): 442 | bill = QRBill(**min_data, alt_procs=['x', 'y', 'z']) 443 | bill = QRBill(**min_data, alt_procs=['ABCDEFGH', '012345678']) 444 | svg_result = self._produce_svg(bill) 445 | self.assertEqual(svg_result[:40], '\n') 446 | self.assertIn('ABCDEFGH', svg_result) 447 | self.assertIn('012345678', svg_result) 448 | 449 | def test_full_page(self): 450 | bill = QRBill( 451 | account="CH 53 8000 5000 0102 83664", 452 | creditor={ 453 | 'name': 'Jane', 'pcode': '1000', 'city': 'Lausanne', 454 | }, 455 | ) 456 | file_head = self._produce_svg(bill, full_page=True)[:250] 457 | self.assertIn('width="210mm"', file_head) 458 | self.assertIn('height="297mm"', file_head) 459 | 460 | def test_as_svg_filelike(self): 461 | bill = QRBill( 462 | account="CH 53 8000 5000 0102 83664", 463 | creditor={ 464 | 'name': 'Jane', 'pcode': '1000', 'city': 'Lausanne', 465 | }, 466 | ) 467 | self.assertEqual(self._produce_svg(bill)[:40], '\n') 468 | 469 | def test_font_factor(self): 470 | bill = QRBill( 471 | account="CH 53 8000 5000 0102 83664", 472 | creditor={ 473 | 'name': 'Jane', 'pcode': '1000', 'city': 'Lausanne', 474 | }, 475 | font_factor=1.5 476 | ) 477 | content = strip_svg_path(self._produce_svg(bill)) 478 | self.assertIn( 479 | 'Receipt' 481 | 'Account / Payable to' 483 | 'CH53 8000 5000 0102 8366 4'.format( 485 | ffamily='Arial,Helvetica', y1=mm(11), y2=mm(16), y3=mm(19.5) 486 | ), 487 | content 488 | ) 489 | 490 | 491 | class CommandLineTests(unittest.TestCase): 492 | def test_no_args(self): 493 | out, err = subprocess.Popen( 494 | [sys.executable, 'scripts/qrbill'], 495 | stdout=subprocess.PIPE, stderr=subprocess.PIPE, 496 | ).communicate() 497 | self.assertIn( 498 | 'error: the following arguments are required: --account, --creditor-name', 499 | err.decode() 500 | ) 501 | 502 | def test_minimal_args(self): 503 | with tempfile.NamedTemporaryFile(suffix='.svg') as tmp: 504 | out, err = subprocess.Popen([ 505 | sys.executable, 'scripts/qrbill', '--account', 'CH 53 8000 5000 0102 83664', 506 | '--creditor-name', 'Jane', '--creditor-postalcode', '1000', 507 | '--creditor-city', 'Lausanne', 508 | '--output', tmp.name, 509 | ], stdout=subprocess.PIPE, stderr=subprocess.PIPE).communicate() 510 | self.assertEqual(err, b'') 511 | 512 | def test_combined_address(self): 513 | with tempfile.NamedTemporaryFile(suffix='.svg') as tmp: 514 | out, err = subprocess.Popen([ 515 | sys.executable, 'scripts/qrbill', '--account', 'CH 53 8000 5000 0102 83664', 516 | '--creditor-name', 'Jane', '--creditor-line1', 'Av. des Fleurs 5', 517 | '--creditor-line2', '1000 Lausanne', 518 | '--output', tmp.name, 519 | ], stdout=subprocess.PIPE, stderr=subprocess.PIPE).communicate() 520 | self.assertEqual(err, b'') 521 | 522 | def test_svg_result(self): 523 | with tempfile.NamedTemporaryFile(suffix='.svg') as tmp: 524 | cmd = [ 525 | sys.executable, 'scripts/qrbill', '--account', 'CH 44 3199 9123 0008 89012', 526 | '--creditor-name', 'Jane', '--creditor-postalcode', '1000', 527 | '--creditor-city', 'Lausanne', '--reference-number', '210000000003139471430009017', 528 | '--extra-infos', 'Order of 15.09.2019', '--output', tmp.name, 529 | ] 530 | out, err = subprocess.Popen( 531 | cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, 532 | ).communicate() 533 | svg_content = tmp.read().decode('utf-8') 534 | self.assertIn('Reference', svg_content) 535 | self.assertIn('Order of 15.09.2019', svg_content) 536 | 537 | def test_text_result(self): 538 | cmd = [ 539 | sys.executable, 'scripts/qrbill', '--account', 'CH 44 3199 9123 0008 89012', 540 | '--creditor-name', 'Jane', '--creditor-postalcode', '1000', 541 | '--creditor-city', 'Lausanne', '--reference-number', '210000000003139471430009017', 542 | '--additional-information', 'Order of 15.09.2019', '--text', 543 | ] 544 | out, err = subprocess.Popen( 545 | cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, 546 | ).communicate() 547 | self.assertEqual(err, b'') 548 | self.assertEqual( 549 | out.decode(), 550 | 'SPC\r\n0200\r\n1\r\nCH4431999123000889012\r\nS\r\nJane' 551 | '\r\n\r\n\r\n' 552 | '1000\r\nLausanne\r\nCH' 553 | '\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\nCHF\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n' 554 | 'QRR\r\n210000000003139471430009017\r\nOrder of 15.09.2019\r\nEPD\n' 555 | ) 556 | 557 | 558 | def strip_svg_path(content): 559 | return re.sub(r']*', '', content) 560 | 561 | 562 | if __name__ == '__main__': 563 | unittest.main() 564 | --------------------------------------------------------------------------------