First implemenatation of invoice generator using python (#1)

Co-authored-by: Torsten Ueberschar <torsten@ueberschar.de>
Reviewed-on: torsten/freelance_invoice#1
This commit is contained in:
2024-09-17 14:01:42 +02:00
parent 17346d5197
commit d0074b7104
30 changed files with 1155 additions and 158 deletions

171
.gitignore vendored
View File

@@ -1,162 +1,19 @@
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
.idea
.code
# C extensions
*.so
# python
# Distribution / packaging
# Virtualenv
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
__pycache__
[Bb]in
[Ii]nclude
[Ll]ib
[Ll]ib64
[Ll]ocal
[Ss]cripts
pyvenv.cfg
venv
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
pip-selfcheck.json

170
README.md
View File

@@ -1,2 +1,170 @@
# freelance_invoice
# Freelance Invoice
is a simple command line tool to generate invoices for freelance work. It uses a simple template system based
on `jinja2` to generate the
invoice in HTML and then converts it to a PDF using `xhtml2pdf`.
## Install
Use python venv:
```bash
python -m venv .venv
source .venv/bin/activate
```
Build executable:
```bash
pip install --upgrade build
python -m build
```
Install the executable:
```bash
pip install .
```
See where it is installed:
```bash
which invoice
```
## usage
```bash
invoice -h
```
There are two yaml files describing your invoice:
- `invoice.yaml` contains the invoice data
- `envelope.yaml` contains the address data of the sender and the recipient
The invoice data is read from the `invoice.yaml` file and the address data is read from the `envelope.yaml` file. The
invoice data is then used to fill in the invoice template and the address data is used to fill in the address fields of
the invoice.
Some data are globaly available from the envelope template. Some of them can be overriden by the invoice template.
invoice.yaml - The Name of the invoice file is used as the invoice number but you are free to override it in the
invoice.yaml file.
```yaml
Id: RG004712 # overrides the file name as invlice Number
CustomerId: KD01234 # The customer id is used to identify the customer and is beeing looked up in the envelope.yaml file.
InvoiceDate: 2023-12-23 # The date of the invoice. If not set the current date is used.
Positions:
- Title: "Zuckerwatte fressen Ganz besonders langer Text"
SubTitle: "Leistungszeitraum: 11/2022"
PricePerUnit: 100 # The price per unit is taken from the envelope.yaml file using the customer id but can be overriden here.
Quantity: 100
- Title: "Aschlecken"
SubTitle: "Leistungszeitraum: 11/2022"
PricePerUnit: 99.99
Quantity: 12
- Title: "Aschkriechen"
SubTitle: "Leistungszeitraum: 10/2022"
PricePerUnit: 77.88
Quantity: 3
```
envelope.yaml
```yaml
AddressContent:
# AddressBoxSender is is used as the sender address in the letter head inside the address windows.
AddressBoxSender: "Abs.: Torsten Ueberschar - Pfarrweg 1 - 57439 Attendorn"
# The Content is a place to put your personal data. E.G. it is used in the footer of the invoice.
# you may put markdown in here.
Contents:
- Text: |
![logo](test_data/templates/images/logo.svg)
Torsten Ueberschar
Pfarrweg 1
57439 Attendorn
- Text: |
**Kontakt**
tu@uesome.de
+49 2734 4239271
- Text: |
**Steuern**
USt-IdNr.: DE313460724
- Text: |
**Bankverbindung**
Torsten Ueberschar
DE67 1001 1001 2626 8627 86
NTSBDEB1XXX
N26 Bank GmbH
# The Invoice field is used to put default values for the invoice. You may override them in the invoice.yaml file.
Invoice:
Vat: 19.0
Introduction: |
### Rechnung
*Sehr geehrte Damen und Herren*,
für folgende in Ihrem Auftrag ausgeführten Leistungen erlaube ich mir zu berechnen
Footer: |
Bitte überweisen Sie den Rechnungsbetrag unter Angabe der Rechnungsnummer auf mein Konto bis zum %%ZahlungsZiel%%.
Mit freundlichen Grüßen
*Torsten Uebeschar*
# Customers is a list of customers. The customer id is used to identify the customer and is beeing looked up in the invoice.yaml file.
Customers:
- CustomerId: KD01234
PricePerUnit: 88.88
DueDate: 30 # DueDate is the number of days the customer has to pay the invoice.
# The AddressField is used to fill in the address field window of the invoice.
AddressField: |
Klaus Peter Klausen
Am Klausenhof 1
04711 Klausenhausen
```
```bash
python src/main.py --help
Simple invoice generator for freelancers and small businesses by Torsten Ueberschar
usage: main.py [-h] -b BASE -i INVOICE [-e ENVELOPE] [-t TEMPLATE]
Read invoice and envelope data from yaml file
options:
-h, --help show this help message and exit
-b BASE, --base BASE base directory for invoice and envelope files
-i INVOICE, --invoice INVOICE
Invoice file name
-e ENVELOPE, --envelope ENVELOPE
Envelope file name
-t TEMPLATE, --template TEMPLATE
directory for template files
```
## example
```bash
invoice -b test_data -i invoice.yaml -e envelope.yaml -t test_data/templates
```
## to do
- give more structure to code
- make code testable
## see also
- https://xhtml2pdf.readthedocs.io/en/latest/index.html
- https://jinja.palletsprojects.com/

29
pyproject.toml Normal file
View File

@@ -0,0 +1,29 @@
[project]
name = "simple-invoice-generator"
version = "0.2.0"
description = "Simple invoice generator for freelancers and small businesses"
readme = "README.md"
authors = [{name = "Torsten Ueberschar", email = "tu@uesome.de"}]
requires-python = ">=3.10"
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers, Freelancers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.10",
]
dependencies = [
'PyYAML',
'Jinja2',
'xhtml2pdf',
'Markdown'
]
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project.scripts]
invoice = "main:main"

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
Jinja2==3.1.3
Markdown==3.5.2
PyYAML==6.0.1
xhtml2pdf==0.2.15

View File

View File

@@ -0,0 +1,72 @@
from pathlib import Path
from jinja2 import FileSystemLoader, TemplateNotFound, Environment
from xhtml2pdf import pisa
from datetime import date, timedelta
import markdown
class HtmlTemplate:
def __init__(self, templates):
self.template_name = 'invoice.html'
if not Path(templates).exists():
raise FileNotFoundError(f'Inivalid path to template files: {templates}')
self.templates = templates
self.path_to_template = Path(templates)
def prepare_template(self, invoice_data, envelope_data):
try:
loader = FileSystemLoader(self.templates)
env = Environment(loader=loader)
env.globals['format_float'] = self.format_float
env.globals['calculate_total'] = self.calculate_total
env.filters['markdown_to_html'] = self.markdown_to_html
env.filters['named_replace'] = self.named_replace
env.filters['add_days'] = self.add_days
template = env.get_template(self.template_name)
return template.render(invoice=invoice_data, envelope=envelope_data)
except TemplateNotFound:
raise FileNotFoundError(f'Could not find template file: {self.template_name}')
@staticmethod
def calculate_total(invoice_data):
return sum((pos['Quantity'] * pos['PricePerUnit']) for pos in invoice_data.Positions)
@staticmethod
def named_replace(value, **replacements):
for key, replacement in replacements.items():
value = value.replace(f"%%{key}%%", str(replacement))
return value
@staticmethod
def format_float(value):
return f'{value:,.2f}'
@staticmethod
def add_days(date, number_of_days):
return date + timedelta(days=number_of_days)
@staticmethod
def markdown_to_html(md_content):
html_content = markdown.markdown(md_content)
return html_content
def convert_html_to_pdf(self, source_html, output_filename):
# open output file for writing (truncated binary)
result_file = open(output_filename, "w+b")
# convert HTML to PDF
pisa_status = pisa.CreatePDF(
source_html, # the HTML to convert
path=str(self.path_to_template / 'fonts'),
dest=result_file
) # file handle to receive result
# close output file
result_file.close() # close output file
# return False on success and True on errors
return pisa_status.err

156
src/main.py Normal file
View File

@@ -0,0 +1,156 @@
import argparse
import yaml
import locale
from pathlib import Path, PurePath
from datetime import datetime
from invoice_generator import html_generator
class DataObject:
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
def parse_yaml_file(file_path):
with open(file_path, 'r', encoding='utf-8') as file:
return yaml.safe_load(file)
def check_file(file_path):
try_out_extensions = ['.yaml', '.yml']
for ext in try_out_extensions:
if file_path.exists():
return file_path
file_path = file_path.with_suffix(ext)
return None
def parse_date(date_string):
return datetime.strptime(date_string, '%Y-%m-%d').date()
def merge_envelope_data_into_invoice_data(invoice_data, source_data):
if (source_data is None) or (invoice_data is None):
return
for key, value in source_data.items():
if not hasattr(invoice_data, key):
setattr(invoice_data, key, value)
# Utility function
def main():
locale.setlocale(locale.LC_ALL, 'de_DE.utf8')
cwd = Path.cwd().resolve()
print('Simple invoice generator for freelancers and small businesses by Torsten Ueberschar')
print()
parser = argparse.ArgumentParser(description='Read invoice and envelope data from yaml file')
parser.add_argument('-b', '--base', type=str, required=False, default=cwd,
help='base directory for invoice and envelope files (default: (current working directory) %(default)s)')
parser.add_argument('-e', '--envelope', type=str, required=False, default='envelope.yaml',
help='Envelope file name (default: %(default)s)')
parser.add_argument('-t', '--template', type=str, required=False, default='templates',
help='directory for template files (default: %(default)s)')
parser.add_argument('-i', '--invoice', type=str, required=True, help='Invoice file name')
args = parser.parse_args()
invoice_file = args.invoice
envelope_file = args.envelope
print(f'Searching for invoice files in: {args.base}')
invoice_file = Path(args.base) / invoice_file
envelope_file = Path(args.base) / envelope_file
invoice_file = check_file(invoice_file)
envelope_file = check_file(envelope_file)
print(f'Found invoice file: {invoice_file}')
print(f'Found envelope file: {envelope_file}')
if invoice_file is None:
print('Error: No invoice file found')
return
if envelope_file is None:
print('Error: No envelope file found')
return
try:
invoice = parse_yaml_file(invoice_file)
envelope = parse_yaml_file(envelope_file)
print('Envelope data:')
envelope_data = DataObject(**envelope)
print('<--->')
print(yaml.dump(envelope_data))
print('</--->')
print('Invoice data:')
invoice_data = DataObject(**invoice)
merge_envelope_data_into_invoice_data(invoice_data, envelope_data.Invoice)
selected_customer = next((x for x in envelope_data.Customers if x['CustomerId'] == invoice_data.CustomerId),
None)
merge_envelope_data_into_invoice_data(invoice_data, selected_customer)
if not hasattr(invoice_data, 'InvoiceDate'):
invoice_data.InvoiceDate = datetime.now().date()
else:
invoice_data.InvoiceDate = parse_date(invoice_data.InvoiceDate)
if not hasattr(invoice_data, 'Id'):
invoice_data.Id = None
if not hasattr(invoice_data, 'Positions'):
invoice_data.Positions = []
for position in invoice_data.Positions:
if 'PricePerUnit' not in position or position['PricePerUnit'] is None:
position['PricePerUnit'] = invoice_data.PricePerUnit
invoice_data.Id = invoice_data.Id or invoice_file.stem
print('<--->')
print(yaml.dump(invoice_data))
print('</--->')
except FileNotFoundError as e:
print(f'Error: {e}')
return
except yaml.YAMLError as e:
print(f'Error: {e}')
return
except Exception as e:
print(f'Error: {e}')
return
if not PurePath(args.template).is_absolute():
template_dir = (Path(args.base) / args.template).resolve()
else:
template_dir = Path(args.template).resolve()
generator = html_generator.HtmlTemplate(template_dir)
template = generator.prepare_template(invoice_data, envelope_data)
try:
print('Generating invoice...')
output_path = Path(args.base) / 'pdf'
output_path.mkdir(parents=True, exist_ok=True)
output_path = output_path / f'{invoice_data.Id}.pdf'
invoice_pdf = output_path.resolve()
print(f'Invoice PDF: {invoice_pdf}')
generator.convert_html_to_pdf(template, invoice_pdf)
except Exception as e:
print(f'Error: {e}')
return
if __name__ == "__main__":
main()

20
test_data/RG004211.yaml Normal file
View File

@@ -0,0 +1,20 @@
#Id: RG004712
CustomerId: KD01234
#InvoiceDate: 2023-12-23
Positions:
- Title: "Zuckerwatte essen"
SubTitle: "Leistungszeitraum: 11/2022"
PricePerUnit: 100
Quantity: 100
- Title: "Sinnloses Zeug"
PricePerUnit: 99.99
Quantity: 12
- Title: "Irgend was anderes"
SubTitle: "Leistungszeitraum: 10/2022"
Quantity: 3

47
test_data/envelope.yaml Normal file
View File

@@ -0,0 +1,47 @@
AddressContent:
LogoFile: Images/logo.svg
AddressBoxSender: "Abs.: Klaus Kleinkariert, Musterstraße 1, 12345 Musterhausen"
Contents:
- Text: |
![logo](images/logo.png)
Klaus Kleinkariert
Musterstraße 1
12345 Musterhausen
- Text: |
**Kontakt**
klaus@klaus-superheld.de
+49 800 12345678
- Text: |
**Steuern**
USt-IdNr.: DE123456789
- Text: |
**Bankverbindung**
Klaus Kleinkariert
DE12 1234 1234 1234 1234 12
ABCDTTCXXX
Geiz Bank GmbH
Invoice:
Vat: 19.0
Introduction: |
### Rechnung
*Sehr geehrte Damen und Herren*,
für folgende in Ihrem Auftrag ausgeführten Leistungen erlaube ich mir zu berechnen
Footer: |
Bitte überweisen Sie den Rechnungsbetrag unter Angabe der Rechnungsnummer auf mein Konto bis zum %%ZahlungsZiel%%.
Mit freundlichen Grüßen
*Klaus Musterhausen*
Customers:
- CustomerId: KD01234
PricePerUnit: 43.50
DueDate: 14
AddressField: |
Beispiel GmbH
Beispielstraße 12
12345 Musterhausen

417
test_data/pdf/RG004211.pdf Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -0,0 +1,227 @@
<html>
<head>
<style>
@font-face {
font-family: "Barlow Semi Condensed";
src: url("fonts/BarlowSemiCondensed-Regular.ttf");
}
@font-face {
font-family: "Barlow Semi Condensed";
src: url("fonts/BarlowSemiCondensed-Light.ttf");
font-style: normal;
font-weight: normal;
}
@font-face {
font-family: "Barlow Semi Condensed";
src: url("fonts/BarlowSemiCondensed-LightItalic.ttf");
font-style: italic;
font-weight: normal;
}
@font-face {
font-family: "Barlow Semi Condensed";
src: url("fonts/BarlowSemiCondensed-Bold.ttf");
font-style: normal;
font-weight: bold;
}
@font-face {
font-family: "Barlow Semi Condensed";
src: url("fonts/BarlowSemiCondensed-BoldItalic.ttf");
font-style: italic;
font-weight: bold;
}
@page {
size: a4 portrait;
margin: 1cm 1cm 1cm 2cm;
font-size: 12pt;
@frame address_frame {
/* Static Frame */
-pdf-frame-content: address_frame_content;
left: 2.5cm;
width: 8.5cm;
top: 5.5cm;
height: 4cm;
}
@frame letter_head_frame {
/* Another static Frame */
-pdf-frame-content: letter_head_content;
left: 15.5cm;
width: 4cm;
top: 1cm;
}
@frame content_first_page_frame {
/* Content Frame */
left: 2cm;
width: 13cm;
top: 11cm;
bottom: 1cm;
}
}
body {
font-family: 'Barlow Semi Condensed';
font-size: 10pt;
}
table {
}
#address_frame_content {
}
#letter_head_content {
}
.head {
}
.positionen {
padding-top: 1pt;
padding-bottom: 1pt;
}
.links {
text-align: left;
}
.rechts {
text-align: right;
}
.summe {
padding-top: 1mm;
border-top: .1pt solid black;
}
.bold {
font-weight: bold;
}
.underline {
border-bottom: .1pt solid black;
}
.overline {
border-top: .1pt solid black;
}
.center {
text-align: center;
}
.head_data {
width: 100%;
border-collapse: collapse;
}
.head_data td {
text-align: center;
}
.small {
font-size: 8pt;
}
#letter_head_content img {
display: block;
margin-left: -.55cm;
}
.position_top {
padding-top: 1pt;
}
.position_bottom {
padding-bottom: 1pt;
}
</style>
</head>
<body>
<!-- Content for Static Frame 'header_frame' -->
<div id="address_frame_content">
<p class="underline small center">{{ envelope.AddressContent.AddressBoxSender }}</p>
<address>
{{ invoice.AddressField | markdown_to_html }}
</address>
</div>
<div id="letter_head_content" class="small">
{% for address in envelope.AddressContent.Contents %}
{{ address.Text | markdown_to_html }}
{% endfor %}
</div>
<!-- HTML Content -->
<table class="head_data">
<tr class="small">
<td>Rechnungsnummer:</td>
<td>Kundennummer:</td>
<td>Rechnungsdatum:</td>
</tr>
<tr>
<td>{{ invoice.Id }}</td>
<td>{{ invoice.CustomerId }}</td>
<td>{{ invoice.InvoiceDate.strftime('%d. %B %Y') }}</td>
</tr>
</tr>
</table>
<section>
<p>{{ invoice.Introduction | markdown_to_html }}</p>
</section>
<section>
<table>
<tr class="head underline">
<th class="links" style="width: 55%">Aktivität</th>
<th class="rechts" style="width: 15%">Anzahl</th>
<th class="rechts" style="width: 15%">Einheit</th>
<th class="rechts" style="width: 15%">Betrag</th>
</tr>
{% for position in invoice.Positions %}
<tr>
<td class="links position_top">{{ position.Title }}</td>
<td class="rechts" rowspan="2">{{ format_float(position.Quantity | float) }}</td>
<td class="rechts" rowspan="2">{{ format_float((position.PricePerUnit or invoice.PricePerUnit) | float) }}
</td>
<td class="rechts" rowspan="2">{{ format_float(position.Quantity * ( position.PricePerUnit or
invoice.PricePerUnit ))}}
</td>
</tr>
<tr>
<td class="links small position_bottom">{{ position.SubTitle }}</td>
</tr>
{% endfor %}
<tr class="summe">
<td></td>
<td colspan="2" class="rechts">Nettosumme:</td>
<td class="rechts">{{ format_float(calculate_total(invoice)) }}</td>
</tr>
<tr>
<td></td>
<td colspan="2" class="rechts">USt. ({{ format_float(invoice.Vat) }}%):</td>
<td class="rechts">{{ format_float(calculate_total(invoice) * ((invoice.Vat / 100))) }}</td>
</tr>
<tr>
<td></td>
<td colspan="2" class="rechts bold overline">Gesamt Summe:</td>
<td class="rechts bold overline">{{ format_float(calculate_total(invoice) * ((invoice.Vat / 100)+1)) }}</td>
</tr>
</table>
</section>
<section>
<p>{{ invoice.Footer | markdown_to_html | named_replace(ZahlungsZiel=(invoice.InvoiceDate |
add_days(invoice.DueDate)).strftime('%d. %B %Y')) }}</p>
</section>
</body>
</html>