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