├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── pyinvoice ├── __init__.py ├── components.py ├── models.py └── templates.py ├── requirements-dev.txt ├── setup.py └── tests ├── __init__.py ├── fixtures └── dist │ └── empty.txt └── test_templates.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | 59 | 60 | .idea 61 | tests/fixtures/dist/*.pdf 62 | .DS_Store -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "2.6" 5 | - "2.7" 6 | - "3.3" 7 | - "3.4" 8 | 9 | env: 10 | - REPORTLAB=2.6 11 | - REPORTLAB=2.7 12 | - REPORTLAB=3.0 13 | - REPORTLAB=3.1.44 14 | - REPORTLAB=3.2 15 | 16 | matrix: 17 | exclude: 18 | - env: REPORTLAB=2.6 19 | python: "3.3" 20 | - env: REPORTLAB=2.6 21 | python: "3.4" 22 | - env: REPORTLAB=2.7 23 | python: "3.3" 24 | - env: REPORTLAB=2.7 25 | python: "3.4" 26 | - env: REPORTLAB=3.0 27 | python: "2.6" 28 | - env: REPORTLAB=3.1.44 29 | python: "2.6" 30 | - env: REPORTLAB=3.2 31 | python: "2.6" 32 | 33 | install: 34 | - pip install reportlab==$REPORTLAB 35 | - python setup.py install 36 | 37 | script: python setup.py test 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 zhangshine 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | PyInvoice 3 | ========= 4 | 5 | .. image:: https://api.travis-ci.org/CiCiApp/PyInvoice.svg?branch=master 6 | :target: https://github.com/CiCiApp/PyInvoice 7 | 8 | Invoice/Receipt Generator. 9 | 10 | Screenshot 11 | ---------- 12 | 13 | .. image:: https://ciciapp.github.io/PyInvoice/dist/invoice.png 14 | :target: https://github.com/CiCiApp/PyInvoice 15 | 16 | Dependency 17 | ---------- 18 | * Reportlab 19 | * Python 2.6+/3.3+ 20 | 21 | +-------------------+-------------------+-------------------+-------------------+-------------------+ 22 | | | Python 2.6 | Python 2.7 | Python 3.3 | Python 3.4 | 23 | +-------------------+-------------------+-------------------+-------------------+-------------------+ 24 | | Reportlab 2.6 | ✓ | ✓ | | | 25 | +-------------------+-------------------+-------------------+-------------------+-------------------+ 26 | | Reportlab 2.7 | ✓ | ✓ | | | 27 | +-------------------+-------------------+-------------------+-------------------+-------------------+ 28 | | Reportlab 3.0 | | ✓ | ✓ | ✓ | 29 | +-------------------+-------------------+-------------------+-------------------+-------------------+ 30 | | Reportlab 3.1.44 | | ✓ | ✓ | ✓ | 31 | +-------------------+-------------------+-------------------+-------------------+-------------------+ 32 | | Reportlab 3.2 | | ✓ | ✓ | ✓ | 33 | +-------------------+-------------------+-------------------+-------------------+-------------------+ 34 | 35 | Install 36 | ------- 37 | 38 | .. code-block:: bash 39 | 40 | pip install pyinvoice 41 | 42 | Usage 43 | ----- 44 | 45 | .. code-block:: python 46 | 47 | from datetime import datetime, date 48 | from pyinvoice.models import InvoiceInfo, ServiceProviderInfo, ClientInfo, Item, Transaction 49 | from pyinvoice.templates import SimpleInvoice 50 | 51 | doc = SimpleInvoice('invoice.pdf') 52 | 53 | # Paid stamp, optional 54 | doc.is_paid = True 55 | 56 | doc.invoice_info = InvoiceInfo(1023, datetime.now(), datetime.now()) # Invoice info, optional 57 | 58 | # Service Provider Info, optional 59 | doc.service_provider_info = ServiceProviderInfo( 60 | name='PyInvoice', 61 | street='My Street', 62 | city='My City', 63 | state='My State', 64 | country='My Country', 65 | post_code='222222', 66 | vat_tax_number='Vat/Tax number' 67 | ) 68 | 69 | # Client info, optional 70 | doc.client_info = ClientInfo(email='client@example.com') 71 | 72 | # Add Item 73 | doc.add_item(Item('Item', 'Item desc', 1, '1.1')) 74 | doc.add_item(Item('Item', 'Item desc', 2, '2.2')) 75 | doc.add_item(Item('Item', 'Item desc', 3, '3.3')) 76 | 77 | # Tax rate, optional 78 | doc.set_item_tax_rate(20) # 20% 79 | 80 | # Transactions detail, optional 81 | doc.add_transaction(Transaction('Paypal', 111, datetime.now(), 1)) 82 | doc.add_transaction(Transaction('Stripe', 222, date.today(), 2)) 83 | 84 | # Optional 85 | doc.set_bottom_tip("Email: example@example.com
Don't hesitate to contact us for any questions.") 86 | 87 | doc.finish() 88 | 89 | 90 | License 91 | ------- 92 | MIT 93 | -------------------------------------------------------------------------------- /pyinvoice/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CiCiApp/PyInvoice/4c7dc7e5ec11cd9b2890b363a136b26dde65a01d/pyinvoice/__init__.py -------------------------------------------------------------------------------- /pyinvoice/components.py: -------------------------------------------------------------------------------- 1 | from reportlab.lib.units import inch 2 | from reportlab.platypus import Paragraph, Table, TableStyle 3 | from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet 4 | from reportlab.lib import colors 5 | 6 | 7 | class CodeSnippet(Paragraph): 8 | style = ParagraphStyle( 9 | name='CodeSnippet', 10 | parent=getSampleStyleSheet()['Code'], 11 | backColor=colors.lightgrey, leftIndent=0, 12 | borderPadding=(5, 5, 5, 5) 13 | ) 14 | 15 | def __init__(self, code): 16 | Paragraph.__init__(self, code, self.style) 17 | 18 | 19 | class SimpleTable(Table): 20 | def __init__(self, data, horizontal_align=None): 21 | Table.__init__(self, data, hAlign=horizontal_align) 22 | 23 | 24 | class TableWithHeader(Table): 25 | def __init__(self, data, horizontal_align=None, style=None): 26 | Table.__init__(self, data, hAlign=horizontal_align) 27 | 28 | default_style = [ 29 | ('INNERGRID', (0, 0), (-1, -1), .25, colors.black), 30 | ('BOX', (0, 0), (-1, -1), .25, colors.black), 31 | ('BACKGROUND', (0, 0), (-1, -len(data)), colors.lightgrey), 32 | ('ALIGN', (0, 0), (-1, -1), 'CENTER'), 33 | ('VALIGN', (0, 0), (-1, -1), 'MIDDLE') 34 | ] 35 | 36 | if style and isinstance(style, list): 37 | default_style.extend(style) 38 | 39 | self.setStyle(TableStyle(default_style)) 40 | 41 | 42 | class PaidStamp(object): 43 | def __init__(self, x, y): 44 | self.x = x 45 | self.y = y 46 | 47 | def __call__(self, canvas, doc): 48 | # "PAID" 49 | canvas.saveState() 50 | canvas.setFontSize(50) 51 | canvas.setFillColor(colors.red) 52 | canvas.setStrokeColor(colors.red) 53 | canvas.rotate(45) 54 | canvas.drawString(self.x, self.y, 'PAID') 55 | canvas.setLineWidth(4) 56 | canvas.setLineJoin(1) # Round join 57 | canvas.rect(self.x - .25 * inch, self.y - .25 * inch, width=2*inch, height=inch) 58 | canvas.restoreState() -------------------------------------------------------------------------------- /pyinvoice/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from decimal import Decimal 3 | 4 | 5 | class PDFInfo(object): 6 | """ 7 | PDF Properties 8 | """ 9 | def __init__(self, title=None, author=None, subject=None): 10 | """ 11 | PDF Properties 12 | :param title: PDF title 13 | :type title: str or unicode 14 | :param author: PDF author 15 | :type author: str or unicode 16 | :param subject: PDF subject 17 | :type subject: str or unicode 18 | """ 19 | self.title = title 20 | self.author = author 21 | self.subject = subject 22 | self.creator = 'PyInvoice (https://ciciapp.com/pyinvoice)' 23 | 24 | 25 | class InvoiceInfo(object): 26 | """ 27 | Invoice information 28 | """ 29 | def __init__(self, invoice_id=None, invoice_datetime=None, due_datetime=None): 30 | """ 31 | Invoice info 32 | :param invoice_id: Invoice id 33 | :type invoice_id: int or str or unicode or None 34 | :param invoice_datetime: Invoice create datetime 35 | :type invoice_datetime: str or unicode or datetime or date 36 | :param due_datetime: Invoice due datetime 37 | :type due_datetime: str or unicode or datetime or date 38 | """ 39 | self.invoice_id = invoice_id 40 | self.invoice_datetime = invoice_datetime 41 | self.due_datetime = due_datetime 42 | 43 | 44 | class AddressInfo(object): 45 | def __init__(self, name=None, street=None, city=None, state=None, country=None, post_code=None): 46 | """ 47 | :type name: str or unicode or None 48 | :type street: str or unicode or None 49 | :type city: str or unicode or None 50 | :type state: str or unicode or None 51 | :type country: str or unicode or None 52 | :type post_code: str or unicode or int or None 53 | """ 54 | self.name = name 55 | self.street = street 56 | self.city = city 57 | self.state = state 58 | self.country = country 59 | self.post_code = post_code 60 | 61 | 62 | class ServiceProviderInfo(AddressInfo): 63 | """ 64 | Service provider/Merchant information 65 | """ 66 | def __init__(self, name=None, street=None, city=None, state=None, country=None, post_code=None, 67 | vat_tax_number=None): 68 | """ 69 | :type name: str or unicode or None 70 | :type street: str or unicode or None 71 | :type city: str or unicode or None 72 | :type state: str or unicode or None 73 | :type country: str or unicode or None 74 | :type post_code: str or unicode or None 75 | :type vat_tax_number: str or unicode or int or None 76 | """ 77 | super(ServiceProviderInfo, self).__init__(name, street, city, state, country, post_code) 78 | self.vat_tax_number = vat_tax_number 79 | 80 | 81 | class ClientInfo(AddressInfo): 82 | """ 83 | Client/Custom information 84 | """ 85 | def __init__(self, name=None, street=None, city=None, state=None, country=None, post_code=None, 86 | email=None, client_id=None): 87 | """ 88 | :type name: str or unicode or None 89 | :type street: str or unicode or None 90 | :type city: str or unicode or None 91 | :type state: str or unicode or None 92 | :type country: str or unicode or None 93 | :type post_code: str or unicode or None 94 | :type email: str or unicode or None 95 | :type client_id: str or unicode or int or None 96 | """ 97 | super(ClientInfo, self).__init__(name, street, city, state, country, post_code) 98 | self.email = email 99 | self.client_id = client_id 100 | 101 | 102 | class Item(object): 103 | """ 104 | Product/Item information 105 | """ 106 | def __init__(self, name, description, units, unit_price): 107 | """ 108 | Item modal init 109 | :param name: Item name 110 | :type name: str or unicode or int 111 | :param description: Item detail 112 | :type description: str or unicode or int 113 | :param units: Amount 114 | :type units: int or str or unicode 115 | :param unit_price: Unit price 116 | :type unit_price: Decimal or str or unicode or int or float 117 | :return: 118 | """ 119 | self.name = name 120 | self.description = description 121 | self.units = units 122 | self.unit_price = unit_price 123 | 124 | @property 125 | def amount(self): 126 | return int(self.units) * Decimal(str(self.unit_price)) 127 | 128 | 129 | class Transaction(object): 130 | """ 131 | Transaction information 132 | """ 133 | def __init__(self, gateway, transaction_id, transaction_datetime, amount): 134 | """ 135 | :param gateway: Payment gateway like Paypal, Stripe etc. 136 | :type gateway: str or unicode 137 | :param transaction_id: 138 | :type transaction_id: int or str or unicode 139 | :param transaction_datetime: 140 | :type transaction_datetime: date or datetime or str or unicode 141 | :param amount: $$ 142 | :type amount: int or float or str or unicode 143 | :return: 144 | """ 145 | self.gateway = gateway 146 | self.transaction_id = transaction_id 147 | self.transaction_datetime = transaction_datetime 148 | self.amount = amount -------------------------------------------------------------------------------- /pyinvoice/templates.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from datetime import datetime, date 3 | from decimal import Decimal 4 | 5 | from reportlab.lib import colors 6 | from reportlab.lib.enums import TA_CENTER, TA_RIGHT 7 | from reportlab.lib.pagesizes import letter 8 | from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle 9 | from reportlab.lib.units import inch 10 | from reportlab.platypus import SimpleDocTemplate, Paragraph, Table, Spacer 11 | 12 | from pyinvoice.components import SimpleTable, TableWithHeader, PaidStamp 13 | from pyinvoice.models import PDFInfo, Item, Transaction, InvoiceInfo, ServiceProviderInfo, ClientInfo 14 | 15 | 16 | class SimpleInvoice(SimpleDocTemplate): 17 | default_pdf_info = PDFInfo(title='Invoice', author='CiCiApp.com', subject='Invoice') 18 | precision = None 19 | 20 | def __init__(self, invoice_path, pdf_info=None, precision='0.01'): 21 | if not pdf_info: 22 | pdf_info = self.default_pdf_info 23 | 24 | SimpleDocTemplate.__init__( 25 | self, 26 | invoice_path, 27 | pagesize=letter, 28 | rightMargin=inch, 29 | leftMargin=inch, 30 | topMargin=inch, 31 | bottomMargin=inch, 32 | **pdf_info.__dict__ 33 | ) 34 | 35 | self.precision = precision 36 | 37 | self._defined_styles = getSampleStyleSheet() 38 | self._defined_styles.add( 39 | ParagraphStyle('RightHeading1', parent=self._defined_styles.get('Heading1'), alignment=TA_RIGHT) 40 | ) 41 | self._defined_styles.add( 42 | ParagraphStyle('TableParagraph', parent=self._defined_styles.get('Normal'), alignment=TA_CENTER) 43 | ) 44 | 45 | self.invoice_info = None 46 | self.service_provider_info = None 47 | self.client_info = None 48 | self.is_paid = False 49 | self._items = [] 50 | self._item_tax_rate = None 51 | self._transactions = [] 52 | self._story = [] 53 | self._bottom_tip = None 54 | self._bottom_tip_align = None 55 | 56 | @property 57 | def items(self): 58 | return self._items[:] 59 | 60 | def add_item(self, item): 61 | if isinstance(item, Item): 62 | self._items.append(item) 63 | 64 | def set_item_tax_rate(self, rate): 65 | self._item_tax_rate = rate 66 | 67 | @property 68 | def transactions(self): 69 | return self._transactions[:] 70 | 71 | def add_transaction(self, t): 72 | if isinstance(t, Transaction): 73 | self._transactions.append(t) 74 | 75 | def set_bottom_tip(self, text, align=TA_CENTER): 76 | self._bottom_tip = text 77 | self._bottom_tip_align = align 78 | 79 | @staticmethod 80 | def _format_value(value): 81 | if isinstance(value, datetime): 82 | value = value.strftime('%Y-%m-%d %H:%M') 83 | elif isinstance(value, date): 84 | value = value.strftime('%Y-%m-%d') 85 | return value 86 | 87 | def _attribute_to_table_data(self, instance, attribute_verbose_name_list): 88 | data = [] 89 | 90 | for property_name, verbose_name in attribute_verbose_name_list: 91 | attr = getattr(instance, property_name) 92 | if attr: 93 | attr = self._format_value(attr) 94 | data.append(['{0}:'.format(verbose_name), attr]) 95 | 96 | return data 97 | 98 | def _invoice_info_data(self): 99 | if isinstance(self.invoice_info, InvoiceInfo): 100 | props = [('invoice_id', 'Invoice id'), ('invoice_datetime', 'Invoice date'), 101 | ('due_datetime', 'Invoice due date')] 102 | 103 | return self._attribute_to_table_data(self.invoice_info, props) 104 | 105 | return [] 106 | 107 | def _build_invoice_info(self): 108 | invoice_info_data = self._invoice_info_data() 109 | if invoice_info_data: 110 | self._story.append(Paragraph('Invoice', self._defined_styles.get('RightHeading1'))) 111 | self._story.append(SimpleTable(invoice_info_data, horizontal_align='RIGHT')) 112 | 113 | def _service_provider_data(self): 114 | if isinstance(self.service_provider_info, ServiceProviderInfo): 115 | props = [('name', 'Name'), ('street', 'Street'), ('city', 'City'), ('state', 'State'), 116 | ('country', 'Country'), ('post_code', 'Post code'), ('vat_tax_number', 'Vat/Tax number')] 117 | 118 | return self._attribute_to_table_data(self.service_provider_info, props) 119 | 120 | return [] 121 | 122 | def _build_service_provider_info(self): 123 | # Merchant 124 | service_provider_info_data = self._service_provider_data() 125 | if service_provider_info_data: 126 | self._story.append(Paragraph('Service Provider', self._defined_styles.get('RightHeading1'))) 127 | self._story.append(SimpleTable(service_provider_info_data, horizontal_align='RIGHT')) 128 | 129 | def _client_info_data(self): 130 | if not isinstance(self.client_info, ClientInfo): 131 | return [] 132 | 133 | props = [('name', 'Name'), ('street', 'Street'), ('city', 'City'), ('state', 'State'), 134 | ('country', 'Country'), ('post_code', 'Post code'), ('email', 'Email'), ('client_id', 'Client id')] 135 | return self._attribute_to_table_data(self.client_info, props) 136 | 137 | def _build_client_info(self): 138 | # ClientInfo 139 | client_info_data = self._client_info_data() 140 | if client_info_data: 141 | self._story.append(Paragraph('Client', self._defined_styles.get('Heading1'))) 142 | self._story.append(SimpleTable(client_info_data, horizontal_align='LEFT')) 143 | 144 | def _build_service_provider_and_client_info(self): 145 | if isinstance(self.service_provider_info, ServiceProviderInfo) and isinstance(self.client_info, ClientInfo): 146 | # Merge Table 147 | table_data = [ 148 | [ 149 | Paragraph('Service Provider', self._defined_styles.get('Heading1')), '', 150 | '', 151 | Paragraph('Client', self._defined_styles.get('Heading1')), '' 152 | ] 153 | ] 154 | table_style = [ 155 | ('SPAN', (0, 0), (1, 0)), 156 | ('SPAN', (3, 0), (4, 0)), 157 | ('LINEBELOW', (0, 0), (1, 0), 1, colors.gray), 158 | ('LINEBELOW', (3, 0), (4, 0), 1, colors.gray), 159 | ('LEFTPADDING', (0, 0), (-1, -1), 0), 160 | ] 161 | client_info_data = self._client_info_data() 162 | service_provider_data = self._service_provider_data() 163 | diff = abs(len(client_info_data) - len(service_provider_data)) 164 | if diff > 0: 165 | if len(client_info_data) < len(service_provider_data): 166 | client_info_data.extend([["", ""]]*diff) 167 | else: 168 | service_provider_data.extend([["", ""]*diff]) 169 | for d in zip(service_provider_data, client_info_data): 170 | d[0].append('') 171 | d[0].extend(d[1]) 172 | table_data.append(d[0]) 173 | self._story.append( 174 | Table(table_data, style=table_style) 175 | ) 176 | else: 177 | self._build_service_provider_info() 178 | self._build_client_info() 179 | 180 | def _item_raw_data_and_subtotal(self): 181 | item_data = [] 182 | item_subtotal = 0 183 | 184 | for item in self._items: 185 | if not isinstance(item, Item): 186 | continue 187 | 188 | item_data.append( 189 | ( 190 | item.name, 191 | Paragraph(item.description, self._defined_styles.get('TableParagraph')), 192 | item.units, 193 | item.unit_price, 194 | item.amount 195 | ) 196 | ) 197 | item_subtotal += item.amount 198 | 199 | return item_data, item_subtotal 200 | 201 | def _item_data_and_style(self): 202 | # Items 203 | item_data, item_subtotal = self._item_raw_data_and_subtotal() 204 | style = [] 205 | 206 | if not item_data: 207 | return item_data, style 208 | 209 | self._story.append( 210 | Paragraph('Detail', self._defined_styles.get('Heading1')) 211 | ) 212 | 213 | item_data_title = ('Name', 'Description', 'Units', 'Unit Price', 'Amount') 214 | item_data.insert(0, item_data_title) # Insert title 215 | 216 | # Summary field 217 | sum_start_y_index = len(item_data) 218 | sum_end_x_index = -1 - 1 219 | sum_start_x_index = len(item_data_title) - abs(sum_end_x_index) 220 | 221 | # ##### Subtotal ##### 222 | rounditem_subtotal = self.getroundeddecimal(item_subtotal, self.precision) 223 | item_data.append( 224 | ('Subtotal', '', '', '', rounditem_subtotal) 225 | ) 226 | 227 | style.append(('SPAN', (0, sum_start_y_index), (sum_start_x_index, sum_start_y_index))) 228 | style.append(('ALIGN', (0, sum_start_y_index), (sum_end_x_index, -1), 'RIGHT')) 229 | 230 | # Tax total 231 | if self._item_tax_rate is not None: 232 | tax_total = item_subtotal * (Decimal(str(self._item_tax_rate)) / Decimal('100')) 233 | roundtax_total = self.getroundeddecimal(tax_total, self.precision) 234 | item_data.append( 235 | ('Vat/Tax ({0}%)'.format(self._item_tax_rate), '', '', '', roundtax_total) 236 | ) 237 | sum_start_y_index += 1 238 | style.append(('SPAN', (0, sum_start_y_index), (sum_start_x_index, sum_start_y_index))) 239 | style.append(('ALIGN', (0, sum_start_y_index), (sum_end_x_index, -1), 'RIGHT')) 240 | else: 241 | tax_total = None 242 | 243 | # Total 244 | total = item_subtotal + (tax_total if tax_total else Decimal('0')) 245 | roundtotal = self.getroundeddecimal(total, self.precision) 246 | item_data.append(('Total', '', '', '', roundtotal)) 247 | sum_start_y_index += 1 248 | style.append(('SPAN', (0, sum_start_y_index), (sum_start_x_index, sum_start_y_index))) 249 | style.append(('ALIGN', (0, sum_start_y_index), (sum_end_x_index, -1), 'RIGHT')) 250 | 251 | return item_data, style 252 | 253 | def getroundeddecimal(self, nrtoround, precision): 254 | d = Decimal(nrtoround) 255 | aftercomma = Decimal(precision) # or anything that has the exponent depth you want 256 | rvalue = Decimal(d.quantize(aftercomma, rounding='ROUND_HALF_UP')) 257 | return rvalue 258 | 259 | def _build_items(self): 260 | item_data, style = self._item_data_and_style() 261 | if item_data: 262 | self._story.append(TableWithHeader(item_data, horizontal_align='LEFT', style=style)) 263 | 264 | def _transactions_data(self): 265 | transaction_table_data = [ 266 | ( 267 | t.transaction_id, 268 | Paragraph(t.gateway, self._defined_styles.get('TableParagraph')), 269 | self._format_value(t.transaction_datetime), 270 | t.amount, 271 | ) for t in self._transactions if isinstance(t, Transaction) 272 | ] 273 | 274 | if transaction_table_data: 275 | transaction_table_data.insert(0, ('Transaction id', 'Gateway', 'Transaction date', 'Amount')) 276 | 277 | return transaction_table_data 278 | 279 | def _build_transactions(self): 280 | # Transaction 281 | transaction_table_data = self._transactions_data() 282 | 283 | if transaction_table_data: 284 | self._story.append(Paragraph('Transaction', self._defined_styles.get('Heading1'))) 285 | self._story.append(TableWithHeader(transaction_table_data, horizontal_align='LEFT')) 286 | 287 | def _build_bottom_tip(self): 288 | if self._bottom_tip: 289 | self._story.append(Spacer(5, 5)) 290 | self._story.append( 291 | Paragraph( 292 | self._bottom_tip, 293 | ParagraphStyle( 294 | 'BottomTip', 295 | parent=self._defined_styles.get('Normal'), 296 | alignment=self._bottom_tip_align 297 | ) 298 | ) 299 | ) 300 | 301 | def finish(self): 302 | self._story = [] 303 | 304 | self._build_invoice_info() 305 | self._build_service_provider_and_client_info() 306 | self._build_items() 307 | self._build_transactions() 308 | self._build_bottom_tip() 309 | 310 | kwargs = {} 311 | if self.is_paid: 312 | kwargs['onFirstPage'] = PaidStamp(7 * inch, 5.8 * inch) 313 | 314 | self.build(self._story, **kwargs) -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | reportlab 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | 4 | try: 5 | from setuptools import setup 6 | except ImportError: 7 | from distutils.core import setup 8 | 9 | 10 | with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as readme: 11 | README = readme.read() 12 | 13 | # allow setup.py to be run from any path 14 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 15 | 16 | 17 | setup( 18 | name='PyInvoice', 19 | version='0.1.7', 20 | packages=['pyinvoice', 'tests'], 21 | include_package_data=True, 22 | license='MIT License', 23 | description='Invoice/Receipt generator', 24 | long_description=README, 25 | url='https://github.com/CiCiApp/PyInvoice', 26 | author='zhangshine', 27 | author_email='zhangshine0125@gmail.com', 28 | install_requires=['reportlab'], 29 | test_suite='tests', 30 | classifiers=[ 31 | 'Intended Audience :: Developers', 32 | 'License :: OSI Approved :: MIT License', 33 | 'Operating System :: OS Independent', 34 | 'Programming Language :: Python', 35 | 'Programming Language :: Python :: 2', 36 | 'Programming Language :: Python :: 2.6', 37 | 'Programming Language :: Python :: 2.7', 38 | 'Programming Language :: Python :: 3', 39 | 'Programming Language :: Python :: 3.2', 40 | 'Programming Language :: Python :: 3.3', 41 | 'Programming Language :: Python :: Implementation :: CPython', 42 | 'Topic :: Software Development :: Libraries :: Python Modules', 43 | ], 44 | ) 45 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CiCiApp/PyInvoice/4c7dc7e5ec11cd9b2890b363a136b26dde65a01d/tests/__init__.py -------------------------------------------------------------------------------- /tests/fixtures/dist/empty.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CiCiApp/PyInvoice/4c7dc7e5ec11cd9b2890b363a136b26dde65a01d/tests/fixtures/dist/empty.txt -------------------------------------------------------------------------------- /tests/test_templates.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | import os 3 | import unittest 4 | from datetime import datetime, date 5 | 6 | from pyinvoice.models import InvoiceInfo, ServiceProviderInfo, ClientInfo, Item, Transaction 7 | from pyinvoice.templates import SimpleInvoice 8 | 9 | 10 | class TestSimpleInvoice(unittest.TestCase): 11 | def setUp(self): 12 | self.file_base_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'fixtures/dist') 13 | 14 | def test_simple(self): 15 | invoice_path = os.path.join(self.file_base_dir, 'simple.pdf') 16 | 17 | if os.path.exists(invoice_path): 18 | os.remove(invoice_path) 19 | 20 | doc = SimpleInvoice(invoice_path) 21 | 22 | doc.is_paid = True 23 | 24 | doc.invoice_info = InvoiceInfo(1023, datetime.now(), datetime.now()) 25 | 26 | doc.service_provider_info = ServiceProviderInfo( 27 | name='PyInvoice', 28 | street='My Street', 29 | city='My City', 30 | state='My State', 31 | country='My Country', 32 | post_code='222222', 33 | vat_tax_number='Vat/Tax number' 34 | ) 35 | 36 | doc.client_info = ClientInfo(email='client@example.com') 37 | 38 | doc.add_item(Item('Item', 'Item desc', 1, '1.1')) 39 | doc.add_item(Item('Item', 'Item desc', 2, '2.2')) 40 | doc.add_item(Item('Item', 'Item desc', 3, '3.3')) 41 | 42 | items = doc.items 43 | self.assertEqual(len(items), 3) 44 | 45 | doc.set_item_tax_rate(20) # 20% 46 | 47 | doc.add_transaction(Transaction('Paypal', 111, datetime.now(), 1)) 48 | doc.add_transaction(Transaction('Stripe', 222, date.today(), 2)) 49 | 50 | transactions = doc.transactions 51 | self.assertEqual(len(transactions), 2) 52 | 53 | doc.set_bottom_tip("Email: example@example.com
Don't hesitate to contact us for any questions.") 54 | 55 | doc.finish() 56 | 57 | self.assertTrue(os.path.exists(invoice_path)) 58 | 59 | def test_only_items(self): 60 | invoice_path = os.path.join(self.file_base_dir, 'only_items.pdf') 61 | if os.path.exists(invoice_path): 62 | os.remove(invoice_path) 63 | 64 | invoice = SimpleInvoice(invoice_path) 65 | 66 | # Before add items 67 | item_data, item_subtotal = invoice._item_raw_data_and_subtotal() 68 | self.assertEqual(len(item_data), 0) 69 | self.assertEqual(item_subtotal, Decimal('0')) 70 | item_data, style = invoice._item_data_and_style() 71 | self.assertEqual(len(item_data), 0) 72 | self.assertEqual(style, []) 73 | 74 | # Add items 75 | invoice.add_item(Item('Item1', 'Item desc', 1, 1.1)) 76 | invoice.add_item(Item('Item2', 'Item desc', 2, u'2.2')) 77 | invoice.add_item(Item(u'Item3', 'Item desc', 3, '3.3')) 78 | 79 | # After add items 80 | items = invoice.items 81 | self.assertEqual(len(items), 3) 82 | self.assertEqual(items[0].name, 'Item1') 83 | self.assertEqual(items[0].amount, Decimal('1.1')) 84 | self.assertEqual(items[1].amount, Decimal('4.4')) 85 | self.assertEqual(items[2].name, u'Item3') 86 | self.assertEqual(items[2].amount, Decimal('9.9')) 87 | 88 | item_data, item_subtotal = invoice._item_raw_data_and_subtotal() 89 | self.assertEqual(item_subtotal, Decimal('15.4')) 90 | self.assertEqual(len(item_data), 3) 91 | 92 | item_data, style = invoice._item_data_and_style() 93 | self.assertEqual(len(item_data), 6) # header, subtotal, total 94 | self.assertEqual(item_data[-2][-1], Decimal('15.4')) # subtotal 95 | self.assertEqual(item_data[-1][-1], Decimal('15.4')) # total 96 | 97 | # test style 98 | # ## Subtotal 99 | self.assertEqual(style[-4], ('SPAN', (0, 4), (3, 4))) 100 | self.assertEqual(style[-3], ('ALIGN', (0, 4), (-2, -1), 'RIGHT')) 101 | # ## Total 102 | self.assertEqual(style[-2], ('SPAN', (0, 5), (3, 5))) 103 | self.assertEqual(style[-1], ('ALIGN', (0, 5), (-2, -1), 'RIGHT')) 104 | 105 | invoice.finish() 106 | 107 | self.assertTrue(os.path.exists(invoice_path)) 108 | 109 | def test_only_items_with_tax_rate(self): 110 | invoice_path = os.path.join(self.file_base_dir, 'only_items_with_tax.pdf') 111 | if os.path.exists(invoice_path): 112 | os.remove(invoice_path) 113 | 114 | invoice = SimpleInvoice(invoice_path) 115 | 116 | # Before add items 117 | item_data, item_subtotal = invoice._item_raw_data_and_subtotal() 118 | self.assertEqual(len(item_data), 0) 119 | self.assertEqual(item_subtotal, Decimal('0')) 120 | item_data, style = invoice._item_data_and_style() 121 | self.assertEqual(len(item_data), 0) 122 | self.assertEqual(style, []) 123 | 124 | # Add items 125 | invoice.add_item(Item('Item1', 'Item desc', 1, 1.1)) 126 | invoice.add_item(Item('Item2', 'Item desc', 2, u'2.2')) 127 | invoice.add_item(Item(u'Item3', 'Item desc', 3, '3.3')) 128 | # set tax rate 129 | invoice.set_item_tax_rate(19) 130 | 131 | # After add items 132 | items = invoice.items 133 | self.assertEqual(len(items), 3) 134 | self.assertEqual(items[0].name, 'Item1') 135 | self.assertEqual(items[0].amount, Decimal('1.1')) 136 | self.assertEqual(items[1].amount, Decimal('4.4')) 137 | self.assertEqual(items[2].name, u'Item3') 138 | self.assertEqual(items[2].amount, Decimal('9.9')) 139 | 140 | item_data, item_subtotal = invoice._item_raw_data_and_subtotal() 141 | self.assertEqual(item_subtotal, Decimal('15.4')) 142 | self.assertEqual(len(item_data), 3) 143 | 144 | item_data, style = invoice._item_data_and_style() 145 | self.assertEqual(len(item_data), 7) # header, subtotal, tax, total 146 | self.assertEqual(item_data[-3][-1], Decimal('15.4')) # subtotal 147 | self.assertEqual(item_data[-2][-1], Decimal('2.93')) # tax 148 | self.assertEqual(item_data[-1][-1], Decimal('18.33')) # total 149 | 150 | invoice.finish() 151 | 152 | self.assertTrue(os.path.exists(invoice_path)) 153 | 154 | def test_invoice_info(self): 155 | invoice_path = os.path.join(self.file_base_dir, 'invoice_info.pdf') 156 | if os.path.exists(invoice_path): 157 | os.remove(invoice_path) 158 | 159 | invoice = SimpleInvoice(invoice_path) 160 | 161 | # Before add invoice info 162 | invoice_info_data = invoice._invoice_info_data() 163 | self.assertEqual(invoice_info_data, []) 164 | 165 | invoice.invoice_info = InvoiceInfo(12) 166 | 167 | # After add invoice info 168 | invoice_info_data = invoice._invoice_info_data() 169 | self.assertEqual(len(invoice_info_data), 1) 170 | self.assertEqual(invoice_info_data[0][0], 'Invoice id:') 171 | self.assertEqual(invoice_info_data[0][1], 12) 172 | 173 | invoice.invoice_info = InvoiceInfo(12, invoice_datetime=datetime(2015, 6, 1)) 174 | invoice_info_data = invoice._invoice_info_data() 175 | self.assertEqual(len(invoice_info_data), 2) 176 | self.assertEqual(invoice_info_data[1][0], 'Invoice date:') 177 | self.assertEqual(invoice_info_data[1][1], '2015-06-01 00:00') 178 | 179 | invoice.finish() 180 | 181 | self.assertTrue(os.path.exists(invoice_path)) 182 | 183 | def test_service_provider_info(self): 184 | invoice_path = os.path.join(self.file_base_dir, 'service_provider_info.pdf') 185 | if os.path.exists(invoice_path): 186 | os.remove(invoice_path) 187 | 188 | invoice = SimpleInvoice(invoice_path) 189 | 190 | # Before add service provider info 191 | info_data = invoice._service_provider_data() 192 | self.assertEqual(info_data, []) 193 | 194 | # Empty info 195 | invoice.service_provider_info = ServiceProviderInfo() 196 | info_data = invoice._service_provider_data() 197 | self.assertEqual(info_data, []) 198 | 199 | invoice.service_provider_info = ServiceProviderInfo( 200 | name='CiCiApp', 201 | street='Street xxx', 202 | city='City ccc', 203 | state='State sss', 204 | country='Country rrr', 205 | post_code='Post code ppp', 206 | vat_tax_number=666 207 | ) 208 | 209 | # After add service provider info 210 | info_data = invoice._service_provider_data() 211 | self.assertEqual(len(info_data), 7) 212 | self.assertEqual(info_data[0][0], 'Name:') 213 | self.assertEqual(info_data[0][1], 'CiCiApp') 214 | self.assertEqual(info_data[4][0], 'Country:') 215 | self.assertEqual(info_data[4][1], 'Country rrr') 216 | self.assertEqual(info_data[6][0], 'Vat/Tax number:') 217 | self.assertEqual(info_data[6][1], 666) 218 | 219 | invoice.finish() 220 | 221 | self.assertTrue(os.path.exists(invoice_path)) 222 | 223 | def test_client_info(self): 224 | invoice_path = os.path.join(self.file_base_dir, 'client_info.pdf') 225 | if os.path.exists(invoice_path): 226 | os.remove(invoice_path) 227 | 228 | invoice = SimpleInvoice(invoice_path) 229 | 230 | # Before add client info 231 | info_data = invoice._client_info_data() 232 | self.assertEqual(info_data, []) 233 | 234 | # Empty info 235 | invoice.client_info = ClientInfo() 236 | info_data = invoice._client_info_data() 237 | self.assertEqual(info_data, []) 238 | 239 | invoice.client_info = ClientInfo( 240 | name='Client ccc', 241 | street='Street sss', 242 | city='City ccc', 243 | state='State sss', 244 | country='Country ccc', 245 | post_code='Post code ppp', 246 | email='Email@example.com', 247 | client_id=3214 248 | ) 249 | 250 | # After add client info 251 | info_data = invoice._client_info_data() 252 | self.assertEqual(len(info_data), 8) 253 | self.assertEqual(info_data[0][0], 'Name:') 254 | self.assertEqual(info_data[0][1], 'Client ccc') 255 | self.assertEqual(info_data[6][0], 'Email:') 256 | self.assertEqual(info_data[6][1], 'Email@example.com') 257 | self.assertEqual(info_data[7][0], 'Client id:') 258 | self.assertEqual(info_data[7][1], 3214) 259 | 260 | invoice.finish() 261 | 262 | self.assertTrue(os.path.exists(invoice_path)) 263 | 264 | def test_transaction(self): 265 | invoice_path = os.path.join(self.file_base_dir, 'transaction.pdf') 266 | if os.path.exists(invoice_path): 267 | os.remove(invoice_path) 268 | 269 | invoice = SimpleInvoice(invoice_path) 270 | 271 | transaction_data = invoice._transactions_data() 272 | self.assertEqual(transaction_data, []) 273 | 274 | invoice.add_transaction(Transaction('A', 1, date.today(), 9.9)) 275 | invoice.add_transaction(Transaction('B', 3, date(2015, 6, 1), 3.3)) 276 | 277 | transaction_data = invoice._transactions_data() 278 | self.assertEqual(len(transaction_data), 3) 279 | self.assertEqual(transaction_data[0][0], 'Transaction id') 280 | self.assertEqual(transaction_data[1][3], 9.9) 281 | self.assertEqual(transaction_data[2][0], 3) 282 | self.assertEqual(transaction_data[2][2], '2015-06-01') 283 | self.assertEqual(transaction_data[2][3], 3.3) 284 | 285 | invoice.finish() 286 | 287 | self.assertTrue(os.path.exists(invoice_path)) 288 | --------------------------------------------------------------------------------