| @@ -0,0 +1 @@ | |||
| __pycache__ | |||
| @@ -0,0 +1,61 @@ | |||
| = Invoice | |||
| This is my quick and dirty invoice system I am going to use for my invoices | |||
| in year 2012. | |||
| The program is higly experimental and users are expected to know at least a | |||
| bit of Python coding. Feedback is appreciated. | |||
| Pavel Šimerda <pavlix@pavlix.net> | |||
| == License | |||
| This version of the source code is released into public domain, future releases | |||
| may adopt a BSD-like license. | |||
| == Requirements | |||
| * Python 3.2 | |||
| * python3-templite 0.5.1 | |||
| * pdflatex | |||
| == Installation | |||
| You don't need to install, just make a symlink to the invoice script | |||
| in ~/bin or /usr/bin/local. | |||
| You will need to make the following symlinks or directories in ~/.invoice/ | |||
| * data (the database) | |||
| * data/$YEAR (current year) | |||
| * tmp | |||
| * output | |||
| * output/$YEAR | |||
| Use the code or error messages in case this list is incomplete. Use -D for | |||
| debugging. | |||
| == User configuration | |||
| For now an empthy ~/.invoice/config should do. | |||
| == Usage | |||
| Export EDITOR and PDF_VIEWER environment variables to choose your favourite | |||
| text editor and PDF viewer. Defaults are 'vim' and 'xdg-open'. | |||
| Manage companies: | |||
| invoice new-company <id> | |||
| invoice edit-company <id> | |||
| invoice delete-company <id> | |||
| Manage invoices: | |||
| invoice new <company-id> | |||
| invoice edit [<number>] | |||
| invoice delete [<number>] | |||
| Generate and display invoice PDF: | |||
| invoice show [<number>] | |||
| @@ -0,0 +1,13 @@ | |||
| #!/usr/bin/python3 | |||
| import os, sys | |||
| base_dir = sys.path[0] | |||
| sys.path[0] = os.path.join(base_dir, "lib") | |||
| print(sys.path) | |||
| import invoice.cli | |||
| if __name__ == '__main__': | |||
| invoice.cli.Application( | |||
| template_path = os.path.join(base_dir, "templates") | |||
| ).run() | |||
| @@ -0,0 +1,215 @@ | |||
| #!/usr/bin/python3 | |||
| import os, sys, argparse, datetime, subprocess | |||
| import invoice.db | |||
| import logging | |||
| log = logging.getLogger() | |||
| class SanityCheckError(Exception): | |||
| pass | |||
| class Application: | |||
| my_company = "my-company" | |||
| editor = os.environ.get("EDITOR") or "vim" | |||
| viewer = os.environ.get("PAGER") or "less" | |||
| tex_program = "pdflatex" | |||
| pdf_program = "xdg-open" | |||
| def __init__(self, template_path): | |||
| self._parse_args() | |||
| self.year = self.args.__dict__.pop("year") | |||
| self.user_path = os.path.expanduser(self.args.__dict__.pop("user_data")) | |||
| self.method = self.args.__dict__.pop("method") | |||
| self.data_path = os.path.join(self.user_path, "data", "{year}", "data", "{directory}") | |||
| self.tmp_path = os.path.join(self.user_path, "tmp") | |||
| self.output_path = os.path.join(self.user_path, "output", "{year}") | |||
| self.template_path = template_path | |||
| self.db = invoice.db.Database( | |||
| year = self.year, | |||
| data_path = self.data_path) | |||
| def _parse_args(self): | |||
| parser = argparse.ArgumentParser( | |||
| description = "Pavel Šimerda's invoice CLI application.", | |||
| conflict_handler = "resolve") | |||
| parser.add_argument("--year", "-Y", action="store") | |||
| parser.add_argument("--user-data", "-d", action="store") | |||
| parser.add_argument("--debug", "-D", action="store_const", dest="log_level", const=logging.DEBUG) | |||
| #parser.add_argument("--verbose", "-v", action="store_const", dest="log_level", const=logging.INFO) | |||
| #parser.add_argument("--config", "-C", action="store") | |||
| parser.set_defaults( | |||
| year = datetime.date.today().year, | |||
| user_data = "~/.invoice", | |||
| log_level = logging.INFO) | |||
| subparsers = parser.add_subparsers(title="subcommands", | |||
| description="valid subcommands", | |||
| help="additional help") | |||
| for list_ in "invoices", "companies": | |||
| for action in "list", "new", "edit", "show", "delete": | |||
| suffix = '' | |||
| if list_ == "companies": | |||
| suffix = "-companies" if action=="list" else "-company" | |||
| method = getattr(self, "do_"+(action+suffix).replace("-", "_")) | |||
| subparser = subparsers.add_parser(action+suffix, help=method.__doc__) | |||
| if action == "new": | |||
| subparser.add_argument("name" if suffix else "company_name") | |||
| if action in ("show", "edit", "delete"): | |||
| subparser.add_argument("selector", nargs="?") | |||
| if action == "delete": | |||
| subparser.add_argument("--force", "-f", action="store_true") | |||
| subparser.set_defaults(method=method) | |||
| self.args = parser.parse_args() | |||
| log.setLevel(self.args.__dict__.pop("log_level")) | |||
| log.debug("Arguments: {}".format(self.args)) | |||
| def run(self): | |||
| try: | |||
| self.method(**vars(self.args)) | |||
| except (SanityCheckError) as error: | |||
| print("Error: {} Use '--force' to suppress this check.".format(error), file=sys.stderr) | |||
| if log.isEnabledFor(logging.DEBUG): | |||
| raise | |||
| except invoice.db.DatabaseError as error: | |||
| print("Error: {}".format(error), file=sys.stderr) | |||
| if log.isEnabledFor(logging.DEBUG): | |||
| raise | |||
| def do_list(self): | |||
| """List invoices.""" | |||
| for item in sorted(self.db.invoices): | |||
| print(item) | |||
| def do_new(self, company_name): | |||
| """Create and edit a new invoice.""" | |||
| item = self.db.invoices.new(company_name) | |||
| self._edit(item._path) | |||
| def do_edit(self, selector): | |||
| """Edit invoice in external editor. | |||
| The external editor is determined by EDITOR environment variable | |||
| using 'vim' as the default. Item is edited in-place. | |||
| """ | |||
| if selector: | |||
| item = self.db.invoices[selector] | |||
| else: | |||
| item = self.db.invoices.last() | |||
| self._edit(item._path) | |||
| def _edit(self, path): | |||
| log.debug("Editing file: {}".format(path)) | |||
| assert os.path.exists(path) | |||
| subprocess.call((self.editor, path)) | |||
| def do_show(self, selector): | |||
| """Generate and view a PDF invoice. | |||
| This requires Tempita 0.5. | |||
| """ | |||
| import tempita | |||
| if selector: | |||
| invoice = self.db.invoices[selector] | |||
| else: | |||
| invoice = self.db.invoices.last() | |||
| issuer = self.db.companies[self.my_company] | |||
| customer = self.db.companies[invoice.company_name] | |||
| invoice_data = invoice.data() | |||
| issuer_data = issuer.data() | |||
| customer_data = customer.data() | |||
| tmp_path = self.tmp_path.format(year=self.year) | |||
| output_path = self.output_path.format(year=self.year) | |||
| log.debug("tmp_path={}".format(tmp_path)) | |||
| tex_template = os.path.join(self.template_path, "invoice.tex") | |||
| tex_file = os.path.join(tmp_path, "{}.tex".format(invoice._name)) | |||
| tmp_pdf_file = os.path.join(tmp_path, "{}.pdf".format(invoice._name)) | |||
| pdf_file = os.path.join(output_path, "{}.pdf".format(invoice._name)) | |||
| log.debug("Invoice: {}".format(invoice_data._data)) | |||
| log.debug("Issuer: {}".format(issuer_data._data)) | |||
| log.debug("Customer: {}".format(customer_data._data)) | |||
| log.debug("Creating TeX invoice...") | |||
| self._check_path(self.tmp_path) | |||
| result = tempita.Template(open(tex_template).read()).substitute( | |||
| invoice=invoice_data, issuer=issuer_data, customer=customer_data) | |||
| open(tex_file, "w").write(str(result)) | |||
| assert(os.path.exists(tex_file)) | |||
| log.debug("Creating PDF invoice...") | |||
| if subprocess.call((self.tex_program, "{}.tex".format(invoice._name)), cwd=tmp_path) != 0: | |||
| raise GenerationError("PDF generation failed.") | |||
| assert(os.path.exists(tmp_pdf_file)) | |||
| log.debug("Moving PDF file to the output directory...") | |||
| self._check_path(output_path) | |||
| os.rename(tmp_pdf_file, pdf_file) | |||
| assert(os.path.exists(pdf_file)) | |||
| log.debug("Running PDF viewer...") | |||
| subprocess.call((self.pdf_program, pdf_file)) | |||
| def _check_path(self, path): | |||
| if not os.path.exists(path): | |||
| raise LookupError("Directory doesn't exist: {}".format(path)) | |||
| def do_delete(self, selector, force): | |||
| """List invoices.""" | |||
| if selector: | |||
| invoice = self.db.invoices[selector] | |||
| else: | |||
| invoice = self.db.invoices.last() | |||
| if not force: | |||
| raise SanityCheckError("It is not recommended to delete invoices.") | |||
| invoice.delete() | |||
| def do_list_companies(self): | |||
| """List companies.""" | |||
| for item in sorted(self.db.companies): | |||
| print(item) | |||
| def do_new_company(self, name): | |||
| """Create and edit a new company.""" | |||
| item = self.db.companies.new(name) | |||
| self._edit(item._path) | |||
| def do_edit_company(self, selector): | |||
| """Edit company in external editor. | |||
| The external editor is determined by EDITOR environment variable | |||
| using 'vim' as the default. Item is edited in-place. | |||
| """ | |||
| item = self.db.companies[selector] | |||
| self._edit(item._path) | |||
| def do_show_company(self, selector): | |||
| """View company in external editor. | |||
| The external viewer is determined by PAGER environment variable | |||
| using 'less' as the default. | |||
| """ | |||
| item = self.db.companies[selector] | |||
| self._show(item._path) | |||
| def _show(self, path): | |||
| log.debug("Viewing file: {}".format(path)) | |||
| assert os.path.exists(path) | |||
| subprocess.call((self.viewer, path)) | |||
| def do_delete_company(self, selector, force): | |||
| """Delete a company.""" | |||
| company = self.db.companies[selector] | |||
| if not force: | |||
| invoices = self.db.invoices.select({"company_name": company._name}) | |||
| if invoices: | |||
| for invoice in invoices: | |||
| log.info("Dependent invoice: {}".format(invoice)) | |||
| raise SanityCheckError("This company is used by some invoices. You should not delete it.") | |||
| company.delete() | |||
| @@ -0,0 +1,8 @@ | |||
| from .base import DatabaseError | |||
| class Database: | |||
| def __init__(self, **config): | |||
| from . import companies | |||
| from . import invoices | |||
| self.companies = companies.Companies(db=self, **config) | |||
| self.invoices = invoices.Invoices(db=self, **config) | |||
| @@ -0,0 +1,193 @@ | |||
| #!/usr/bin/python3 | |||
| import os, sys, re, time, datetime | |||
| import logging | |||
| log = logging.getLogger() | |||
| class DatabaseError(Exception): | |||
| pass | |||
| class ItemNotFoundError(DatabaseError, LookupError): | |||
| pass | |||
| class ItemNameCheckError(DatabaseError, ValueError): | |||
| pass | |||
| class ItemExistsError(DatabaseError): | |||
| pass | |||
| class List: | |||
| """Base class for database lists. | |||
| This class provides a real-time view to a file-based database. It can be | |||
| used as an iterable with a little bit of magic (like indexing by a | |||
| dictionary of matching attributes). | |||
| """ | |||
| def __init__(self, year, data_path, db=None): | |||
| self._year = year | |||
| self._path = os.path.expanduser(data_path.format( | |||
| year=year, directory=self._directory)) | |||
| self._db = db | |||
| log.debug("{}: {}".format(self.__class__.__name__, self._path)) | |||
| def _item_class(self): | |||
| """Returns class object used to instantiate items. | |||
| Override in subclasses. | |||
| """ | |||
| return Item | |||
| def _item_name(self): | |||
| return self._item_class().__name__.lower() | |||
| def __iter__(self): | |||
| for name in os.listdir(self._path): | |||
| match = self._regex.match(name) | |||
| if match: | |||
| yield self._item_class()(self, year=self._year, **match.groupdict()) | |||
| def last(self): | |||
| return max(iter(self), key=lambda item: item._name) | |||
| def __contains__(self, selector): | |||
| return bool(self._select(selector)) | |||
| def __getitem__(self, selector): | |||
| items = self._select(selector) | |||
| assert(len(items) < 2) | |||
| if not items: | |||
| raise ItemNotFoundError("{} '{}' not found.".format(self._item_class().__name__, selector)) | |||
| item = items[0] | |||
| log.debug("Found matching item: {}".format(item)) | |||
| return item | |||
| def select(self, selector=None): | |||
| return sorted(self._select(selector)) | |||
| def _select(self, selector): | |||
| """Return a list of items matching 'name', 'number' or other attributes.""" | |||
| if selector == None: | |||
| selector = {} | |||
| if isinstance(selector, str): | |||
| selector = {"name": selector} | |||
| if isinstance(selector, int): | |||
| selector = {"number": number} | |||
| log.debug("Selecting: {}".format(selector)) | |||
| assert isinstance(selector, dict) | |||
| return [item for item in self if all(getattr(item, key) == selector[key] for key in selector)] | |||
| def new(self, name): | |||
| """Create a new item in this list. | |||
| Keyword arguments: | |||
| name -- filesystem name of the new item | |||
| edit -- edit the item after creation | |||
| Returns | |||
| """ | |||
| log.info("Creating {}: {}".format(self._item_name(), name)) | |||
| if not self._regex.match(name): | |||
| raise ItemNameCheckError("Name {} doesn't match {} regex.".format(name, self._item_name())) | |||
| if name in self: | |||
| raise ItemExistsError("Item {} of type {} already exists.".format(name, self._item_name())) | |||
| self._new(os.path.join(self._path, name)) | |||
| return self[name] | |||
| def _new(self, path): | |||
| log.debug("Creating {} file: {}".format(self._item_name(), path)) | |||
| stream = os.fdopen(os.open(path, os.O_WRONLY|os.O_EXCL|os.O_CREAT, 0o644), "w") | |||
| stream.write(self.data_template) | |||
| stream.close() | |||
| class Item: | |||
| """Base class for database list items.""" | |||
| def __init__(self, list_, **selector): | |||
| self._list = list_ | |||
| self._selector = selector | |||
| self._postprocess() | |||
| self._name = self._list._template.format(**selector) | |||
| self._path = os.path.join(list_._path, self._name) | |||
| log.debug("{!r}".format(self)) | |||
| def _postprocess(self): | |||
| """Postprocess the _selector attribute. | |||
| Override in subclasses. | |||
| """ | |||
| def __lt__(self, other): | |||
| return self._name < other._name | |||
| def __repr__(self): | |||
| return "{}({!r}, **{})".format(self.__class__.__name__, self._name, self._selector) | |||
| def __str__(self): | |||
| return self._name | |||
| def __getattr__(self, key): | |||
| return self._selector[key] | |||
| def delete(self): | |||
| log.info("Deleting: {}".format(self)) | |||
| path = self._path | |||
| newpath = path + "~" | |||
| log.debug("Renaming file {} to {}.".format(path, newpath)) | |||
| assert os.path.exists(path) | |||
| os.rename(path, newpath) | |||
| def data(self): | |||
| """Return item's data. | |||
| Override in subclasses. | |||
| """ | |||
| return self._data_class()(self) | |||
| def _data_class(self): | |||
| return Data | |||
| class Data: | |||
| """Base class for database list item data objects.""" | |||
| _fields = [] | |||
| _multivalue_fields = [] | |||
| _line_regex = re.compile(r"^([A-Z][a-zA-Z-]*):\s+(.*?)\s+$") | |||
| def __init__(self, item): | |||
| self._item = item | |||
| self._parse(open(self._item._path)) | |||
| self._postprocess() | |||
| def __getattr__(self, key): | |||
| return self._data[key] | |||
| def _parse(self, stream): | |||
| self._data = self._item._selector.copy() | |||
| for f in self._fields: | |||
| self._data[f] = None | |||
| for f in self._multivalue_fields: | |||
| self._data[f] = [] | |||
| for n, line in enumerate(stream): | |||
| match = self._line_regex.match(line) | |||
| if not match: | |||
| log.warning("Ignoring {}:{}: {}".format(n, self._item._name, line)) | |||
| continue | |||
| key, value = match.groups() | |||
| key = key.lower().replace("-", "_") | |||
| if key in self._fields: | |||
| self._data[key] = value | |||
| elif key in self._multivalue_fields: | |||
| self._data[key].append(value) | |||
| else: | |||
| log.warning("Key ignored: {}".format(key)) | |||
| def _postprocess(self): | |||
| """Postprocess item data. | |||
| Override in subclasses. | |||
| """ | |||
| def rename_key(self, oldkey, newkey): | |||
| """Convenience function mainly intended for subclasses.""" | |||
| if not self.__dict__.get(newkey) and oldkey in self._data: | |||
| self._data[newkey] = self._data[oldkey] | |||
| del self._data[oldkey] | |||
| @@ -0,0 +1,42 @@ | |||
| #!/usr/bin/python3 | |||
| import os, sys, re, time, datetime | |||
| import logging | |||
| log = logging.getLogger() | |||
| from invoice.db.base import * | |||
| class Companies(List): | |||
| """Company list. | |||
| When editing data files, you can use the following directives: | |||
| Name -- full company name | |||
| Address -- company address, repeat to get multiple lines | |||
| Number -- identification number | |||
| Comment -- additional information that you want to see on the invoice | |||
| """ | |||
| _directory = "companies" | |||
| _regex = re.compile("^(?P<name>[a-z0-9-]+)$") | |||
| _template = "{name}" | |||
| _data_template = """\ | |||
| Name: | |||
| Address: | |||
| Address: | |||
| Number: | |||
| """ | |||
| def _item_class(self): | |||
| return Company | |||
| class Company(Item): | |||
| def _data_class(self): | |||
| return CompanyData | |||
| class CompanyData(Data): | |||
| _fields = ["name", "number", "ic", "bank_account"] | |||
| _multivalue_fields = ["address", "comment"] | |||
| def _postprocess(self): | |||
| self.rename_key("ic", "number") | |||
| self.rename_key("comment", "comments") | |||
| @@ -0,0 +1,109 @@ | |||
| #!/usr/bin/python3 | |||
| import os, sys, re, time, datetime | |||
| import logging | |||
| log = logging.getLogger() | |||
| from invoice.db.base import * | |||
| class Invoices(List): | |||
| """Company invoice. | |||
| When editing data files, you can use the following | |||
| directives: | |||
| Item -- price, followed by ':' and description | |||
| Due -- Due date YYYY-MM-DD | |||
| Note -- a note at the and of the invoice | |||
| """ | |||
| data_template = """\ | |||
| Item: 0000: Item summary | |||
| """ | |||
| _directory = "income" | |||
| _regex = re.compile("^(?P<date>[0-9]{8})-(?P<number>[0-9]{3})-(?P<company_name>[a-z0-9-]+)$") | |||
| _template = "{date}-{number:03}-{company_name}" | |||
| def _item_class(self): | |||
| return Invoice | |||
| def _select(self, selector): | |||
| if isinstance(selector, str): | |||
| match = self._regex.match(selector) | |||
| if not match: | |||
| raise ItemNotFoundError("Item not found: {}".format(selector)) | |||
| selector = match.groupdict() | |||
| selector["number"] = int(selector["number"]) | |||
| return super(Invoices, self)._select(selector) | |||
| def new(self, company_name): | |||
| if company_name not in self._db.companies: | |||
| raise ItemNotFoundError("Company '{}' not found.".format(company_name)) | |||
| try: | |||
| number = max(item.number for item in self) + 1 | |||
| except ValueError: | |||
| number = 1 | |||
| date = time.strftime("%Y%m%d") | |||
| name = self._template.format(**vars()) | |||
| return super(Invoices, self).new(name) | |||
| class Invoice(Item): | |||
| def _data_class(self): | |||
| return InvoiceData | |||
| def _postprocess(self): | |||
| self._selector["number"] = int(self.number) | |||
| class InvoiceData(Data): | |||
| _fields = ["payment"] | |||
| _multivalue_fields = ["item", "address", "note"] | |||
| _date_regex = re.compile(r"^(\d{4})-?(\d{2})-?(\d{2})$") | |||
| _item_regex = re.compile(r"^(\d+)[:;]\s*(.*)$") | |||
| _number_template = "{year}{number:03}" | |||
| def _parse_date(self, date): | |||
| match = self._date_regex.match(self.date) | |||
| if not match: | |||
| raise ValueError("Bad date format: {}".format(date)) | |||
| return datetime.date(*(int(f) for f in match.groups())) | |||
| def _postprocess(self): | |||
| log.debug(self._data) | |||
| self._postprocess_number() | |||
| self._postprocess_items() | |||
| self._postprocess_dates() | |||
| self.rename_key("note", "notes") | |||
| def _postprocess_number(self): | |||
| self._data["number"] = self._number_template.format( | |||
| year = self.year, | |||
| number = self.number) | |||
| def _postprocess_items(self): | |||
| items = [] | |||
| for item in self.item: | |||
| match = self._item_regex.match(item) | |||
| if not match: | |||
| raise ValueError("Bad item format: {}".format(item)) | |||
| price, description = match.groups() | |||
| items.append((description, int(price))) | |||
| del self._data["item"] | |||
| self._data["items"] = items | |||
| self._data["sum"] = sum(item[1] for item in items) | |||
| def _postprocess_dates(self): | |||
| date = self._parse_date(self._item.date) | |||
| if "due" in self._data: | |||
| try: | |||
| due = self._parse_date(self.due) | |||
| except ValueError: | |||
| try: | |||
| due = datetime.timedelta(int(re.sub("^+", "", self.due))) | |||
| except ValueError: | |||
| raise ValueError("Bad due format: {}".format(self.due)) | |||
| else: | |||
| due = datetime.timedelta(14) | |||
| if isinstance(due, datetime.timedelta): | |||
| due += date | |||
| self._data["date"] = date | |||
| self._data["due"] = due | |||
| @@ -0,0 +1,82 @@ | |||
| \documentclass[10pt]{article} | |||
| \usepackage[utf8]{inputenc} | |||
| \usepackage[czech]{babel} | |||
| \usepackage{a4wide} | |||
| \usepackage{tabularx} | |||
| \renewcommand{\familydefault}{\sfdefault} | |||
| \setlength{\extrarowheight}{3pt} | |||
| \begin{document} | |||
| \footnotesize | |||
| \begin{center} | |||
| \begin{tabularx}{\textwidth}{|XXXX|} | |||
| \cline{3-4} | |||
| \multicolumn{2}{X}{} & \multicolumn{2}{|X|}{} \\ | |||
| \multicolumn{2}{X}{} & \multicolumn{2}{|l|}{\large Faktura: \hfill {{invoice.number}}} \\ | |||
| \multicolumn{2}{X}{} & \multicolumn{2}{|X|}{} \\ | |||
| \hline | |||
| & & & \\ | |||
| \bf Dodavatel: & & \bf Odběratel & \\[1em] | |||
| \multicolumn{2}{|l}{\large\bf {{issuer.name}}} & \multicolumn{2}{l|}{\large\bf {{customer.name}}} \\ | |||
| {{py: | |||
| a1 = issuer.address[:] | |||
| a2 = customer.address[:] | |||
| a1 += (len(a2)-len(a1))*[""] | |||
| a2 += (len(a1)-len(a2))*[""] | |||
| }} | |||
| {{for f1, f2 in zip(a1, a2)}} | |||
| \multicolumn{2}{|l}{\large {{f1}}} & \multicolumn{2}{l|}{\large {{f2}}} \\ | |||
| {{endfor}} | |||
| & & & \\ | |||
| \multicolumn{2}{|l}{IČ: {{issuer.number}}} & \multicolumn{2}{l|}{IČ: {{customer.number}}} \\ | |||
| & & & \\ | |||
| {{py: | |||
| a1 = issuer.comments[:] | |||
| a2 = customer.comments[:] | |||
| a1 += (len(a2)-len(a1))*[""] | |||
| a2 += (len(a1)-len(a2))*[""] | |||
| }} | |||
| {{for f1, f2 in zip(a1, a2)}} | |||
| \multicolumn{2}{|l}{ {{f1}}} & \multicolumn{2}{l|}{ {{f2}}} \\ | |||
| {{endfor}} | |||
| & & & \\ | |||
| \hline | |||
| & & & \\ | |||
| \bf Platební podmínky: & & & \\[1em] | |||
| \large Forma úhrady: & \large {{"hotově" if invoice.payment=="cash" else "převodem"}} & \large Datum vystavení: & \multicolumn{1}{r|}{\large {{invoice.date.strftime("%d.%m.%Y")}}} \\ | |||
| \large Číslo účtu: & \large {{issuer.bank_account}} & \multicolumn{2}{l|}{\large\bf Datum splatnosti: \hfill {{invoice.due.strftime("%d.%m.%Y")}}} \\ | |||
| \large Variabilní symbol: & \large {{invoice.number}} & & \\ | |||
| {{if invoice.notes}} | |||
| & & & \\ | |||
| \hline | |||
| & & & \\ | |||
| \bf Poznámky: & & & \\[1em] | |||
| {{for note in invoice.notes}} | |||
| \multicolumn{4}{|l|}{\large {{note}}} \\ | |||
| {{endfor}} | |||
| {{endif}} | |||
| & & & \\ | |||
| \hline | |||
| & & & \\ | |||
| \bf Fakturujeme vám: & & & \\[1em] | |||
| {{for item in invoice.items}} | |||
| \multicolumn{4}{|l|}{\normalsize {{item[0]}} \hfill {{item[1]}} Kč} \\ | |||
| {{endfor}} | |||
| & & & \\ | |||
| \hline | |||
| & & & \\ | |||
| \large\bf Celkem k úhradě: & & & \multicolumn{1}{r|}{\large\bf {{invoice.sum}} Kč} \\ | |||
| & & & \\ | |||
| \hline | |||
| \end{tabularx} | |||
| \end{center} | |||
| \end{document} | |||