├── .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 |
--------------------------------------------------------------------------------