From f357a2cb3a49bfc6b5d4ef56d64470b2a6bfba46 Mon Sep 17 00:00:00 2001 From: dtrai2 Date: Sat, 22 Jun 2024 17:24:16 +0200 Subject: [PATCH] initial commit --- .gitignore | 30 +++ LICENSE | 21 ++ README.md | 67 +++++++ pyproject.toml | 50 +++++ shiny_invoice/__init__.py | 0 shiny_invoice/default_invoice_template.html | 204 ++++++++++++++++++++ shiny_invoice/shiny_invoice.py | 53 +++++ shiny_invoice/ui_config.py | 30 +++ shiny_invoice/ui_existing_invoices.py | 124 ++++++++++++ shiny_invoice/ui_new_invoice.py | 124 ++++++++++++ 10 files changed, 703 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 shiny_invoice/__init__.py create mode 100644 shiny_invoice/default_invoice_template.html create mode 100644 shiny_invoice/shiny_invoice.py create mode 100644 shiny_invoice/ui_config.py create mode 100644 shiny_invoice/ui_existing_invoices.py create mode 100644 shiny_invoice/ui_new_invoice.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ad8127 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +.idea +.venv +config.yaml +invoice_template.html +invoices diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b311e39 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 dtrai2 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1403c15 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# Shiny Invoice + +This tool is meant as a simple and naive way to manage invoices locally. +It is not meant to be published online and used as an enterprise tool. + + +## Run + +To run shiny invoice you need to clone this repository and install the dependencies with (it is suggested to use a venv) + +```bash +pip install -e . +``` + +Once `shiny-invoice` is installed you need to create configuration file. +A suitable example looks like this: + +```yaml +paths: + invoices_root_dir: /home/user/invoices/ # must be an absolute path, and it needs to end with / + invoices_dir_paid: paid + invoices_dir_unpaid: open + html_template: shiny_invoice/default_invoice_template.html +company: # here you can specify details of your company + name: Company Name + skills: + - Primary Skill + - Secondary Skill + address: + - Address line 1 + - 4234 Addresline2 + contact: + - contact@shinyinvoice.de + - +49 123 456789 + - shinyinvoice.de + bank: + name: SomeBank + iban: DE12 1234 5678 9100 00 + bic: BICCCCCCC + tax_number: 11/2222/3333 + tax_rate: 0.19 + payment_terms_days: 14 +invoice_defaults: # here you can set defaults, which will be used to prefill the invoice formular + recipient: |- + Comp 2 + Compstreet Comp + 1335 Compvill + items: | + Services, Hours, Rate, Price + Service 1, 40h, 100 €, 4.000 € +``` + +Once everything is set up you can run `shiny-invoice` with: + +```bash +shiny-invoice run --config config.yaml +``` + +## Workflow + +This application manages the invoices as plain html files, which then can be turned into pdfs via the +browsers print functionality. +According to the configuration invoices can be separated into paid/unpaid directories. +Based on that they will be also categorised inside the ui. +The application does not offer ways to move or change files, this has to be done manually. +The filename also needs to follow a specific pattern which is +`--.html` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a92a579 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,50 @@ +[build-system] +requires = ["setuptools>=68.0.0", "wheel", "setuptools_scm>=8"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["shiny_invoice"] + +[tool.setuptools_scm] + +[project] +name = "shiny-invoice" +description = "Simply manage invoices" +dynamic = ["version"] +requires-python = ">=3.10" +readme = "README.md" +license = { file = "LICENSE" } +classifiers = [ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Topic :: Office/Business :: Financial :: Accounting", +] +keywords = [ + "invoice", + "finance", + "accounting", +] + +dependencies = [ + "shiny", + "click", + "ruamel.yaml", + "pandas" +] + +[project.optional-dependencies] +dev = [ + "black", + "pylint", + "pre-commit" +] + +[project.scripts] +shiny-invoice = "shiny_invoice.shiny_invoice:cli" + +[tool.black] +line-length = 100 +target-version = ['py311'] diff --git a/shiny_invoice/__init__.py b/shiny_invoice/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shiny_invoice/default_invoice_template.html b/shiny_invoice/default_invoice_template.html new file mode 100644 index 0000000..ef686c6 --- /dev/null +++ b/shiny_invoice/default_invoice_template.html @@ -0,0 +1,204 @@ + + + + + + + +
+
+
+ ${name}
+ ${primary_skills} +
+
+ Invoice ${invoice_number}
Due date: ${due_at_date} +
+
+
+
+ ${name} | ${piped_address}
+ ${recipient_address} +
+
+ ${name}
+ ${linebreaked_address}
+ ${primary_contact} +
+
+ +
+ Invoice + ${created_at_date} +
+ +
+ ${introduction}
+ I appreciate your trust and would ask you to pay this invoice, without deductions, till the ${due_at_date}. +
+ ${invoice_items} +
+ + + + +
Net total:${total_net}
VAT ${tax_rate}:${tax}
Gross total:${total_gross}
+
+
+

Please make the payment to:
+ IBAN: ${iban} at the ${bank} (BIC: ${bic})
+ with the reference Invoice ${invoice_number}.

+ +

Best Regards
${name}

+
+ +
+ + diff --git a/shiny_invoice/shiny_invoice.py b/shiny_invoice/shiny_invoice.py new file mode 100644 index 0000000..683f687 --- /dev/null +++ b/shiny_invoice/shiny_invoice.py @@ -0,0 +1,53 @@ +""" +Shiny Invoice is a simple tool to create invoices and view existing once. +This module acts as the entrypoint, containing the main navigational layout and server, +as well as the cli. +""" + +from pathlib import Path + +import click +from ruamel.yaml import YAML +from shiny import App, Inputs, Outputs, Session, ui + +from shiny_invoice.ui_config import config_ui, config_server +from shiny_invoice.ui_existing_invoices import existing_invoices_ui, existing_invoices_server +from shiny_invoice.ui_new_invoice import new_invoice_ui, new_invoice_server + +yaml = YAML(typ="rt", pure=True) + + +@click.group(name="shiny-invoice") +def cli(): + ... + + +@cli.command(short_help="Run Shiny Invoice") +@click.option("--config", type=click.Path(exists=True), required=True, help="Path to the configuration yaml file.") +@click.option("--host", type=str, default="0.0.0.0", help="Host used for the server, defaults to '0.0.0.0'.") +@click.option("--port", type=int, default=8000, help="Port used for the server, defaults to '8000'.") +def run(config: Path, host: str, port: int): + """Run shiny invoice""" + with open(config, "r", encoding="utf8") as file: + config_str = file.read() + config = yaml.load(config_str) + + app_ui = ui.page_navbar( + ui.nav_panel("Existing Invoices", existing_invoices_ui("existing_invoices")), + ui.nav_panel("Create Invoice", new_invoice_ui("new_invoice", config)), + ui.nav_panel("Configuration", config_ui("config")), + title="Shiny Invoice", + id="navbar_id", + ) + + def server(input: Inputs, output: Outputs, session: Session): + existing_invoices_server("existing_invoices", config) + new_invoice_server("new_invoice", config) + config_server("config", config) + + app = App(app_ui, server, static_assets=config.get("paths").get("invoices_root_dir")) + app.run(host=host, port=port) + + +if __name__ == "__main__": + cli() diff --git a/shiny_invoice/ui_config.py b/shiny_invoice/ui_config.py new file mode 100644 index 0000000..e488f49 --- /dev/null +++ b/shiny_invoice/ui_config.py @@ -0,0 +1,30 @@ +""" +This module contains the ui and server for the configuration view. It simply displays the configuration inside the +shiny invoice application +""" +import io + +from ruamel.yaml import YAML +from shiny import module, ui, render + +yaml = YAML(typ="rt", pure=True) + + +@module.ui +def config_ui(): + return ui.div( + ui.card( + ui.card_header("Configuration"), + ui.output_code("config_output") + ) + ) + + +@module.server +def config_server(input, output, session, config): + @render.text + def config_output(): + """Dump the configuration into a string and return it""" + buffer = io.BytesIO() + yaml.dump(config, buffer) + return buffer.getvalue().decode("utf8") diff --git a/shiny_invoice/ui_existing_invoices.py b/shiny_invoice/ui_existing_invoices.py new file mode 100644 index 0000000..ece039a --- /dev/null +++ b/shiny_invoice/ui_existing_invoices.py @@ -0,0 +1,124 @@ +"""This module contains the ui and server configurations for the existing invoices.""" + +import datetime +import glob +from pathlib import Path + +import pandas as pd +from shiny import module, ui, render, reactive + + +@module.ui +def existing_invoices_ui(): + return ui.div( + ui.card( + ui.card_header("Filter"), + ui.layout_columns( + ui.tooltip( + ui.input_text("invoice_numbers", "Filter by invoices", placeholder="13,21,37"), + "Comma separated" + ), + ui.input_date_range( + id="daterange", + label="Filter by Date range", + start=f"{datetime.date.today().year}-01-01" + ), + ui.input_checkbox_group( + id="paid_status", label= + "Paid Status", + choices={"paid": "Paid", "unpaid": "Unpaid"}, + inline=True + ) + ) + ), + ui.card( + ui.layout_column_wrap( + ui.card( + ui.card_header("List of filtered invoices"), + ui.output_data_frame("invoice_list") + ), + ui.card( + ui.card_header("Selected Invoice"), + ui.output_ui("selected_invoice") + ) + ) + ) + ) + + +@module.server +def existing_invoices_server(input, output, session, config): + @reactive.calc + def get_filtered_invoices() -> pd.DataFrame | str: + """Retrieve all invoices from the configured directories and parse them into a DataFrame. The input filters + will then be applied to the dataframe such that only the desired results will be returned.""" + paid_records, unpaid_records = _get_invoice_records() + df = pd.DataFrame.from_records(paid_records + unpaid_records) + if len(df) == 0: + return df + duplicate_numbers = df[df.duplicated(["Invoice"], keep="last")] + if len(duplicate_numbers) > 0: + duplicate_ids = ", ".join(duplicate_numbers['Invoice'].to_list()) + ui.notification_show(f"Found duplicate invoice ids: {duplicate_ids}", type="warning", duration=2) + df = _filter_invoices(df) + return df + + def _get_invoice_records(): + root_dir = Path(config.get("paths").get("invoices_root_dir")) + paid_dir = root_dir / config.get("paths").get("invoices_dir_paid") + unpaid_dir = root_dir / config.get("paths").get("invoices_dir_unpaid") + paid_invoices = glob.glob(f"{paid_dir}/**/*.html", recursive=True) + unpaid_invoices = glob.glob(f"{unpaid_dir}/**/*.html", recursive=True) + paid_records = _create_invoice_records(paid_invoices, status="paid") + unpaid_records = _create_invoice_records(unpaid_invoices, status="unpaid") + return paid_records, unpaid_records + + def _filter_invoices(df): + if input.invoice_numbers(): + filtered_invoice_ids = input.invoice_numbers().split(",") + df = df.loc[df["Invoice"].isin(filtered_invoice_ids)] + if input.paid_status(): + df = df.loc[df["Status"].isin(input.paid_status())] + start_date = input.daterange()[0] + end_date = input.daterange()[1] + df = df[(df["Date"] >= start_date) & (df["Date"] <= end_date)] + df["Date"] = df["Date"].apply(lambda x: x.strftime("%d.%m.%Y")) + return df + + def _create_invoice_records(file_paths, status): + records = [] + for invoice_path in file_paths: + parts = invoice_path.split("/") + name_parts = parts[-1].split("-") + date = datetime.date(year=int(name_parts[0]), month=int(name_parts[1]), day=int(name_parts[2])) + invoice_number = name_parts[3] + customer = name_parts[-1].replace(".html", "") + root_dir = config.get("paths").get("invoices_root_dir") + invoice_path = invoice_path.replace(root_dir, "") + records.append({ + "Date": date, + "Invoice": invoice_number, + "Status": status, + "Customer": customer, + "Link": ui.a("Download", href=invoice_path, target="_blank"), + }) + return records + + @render.data_frame + def invoice_list(): + """Render a list of filtered invoices""" + df = get_filtered_invoices() + return render.DataGrid(df, selection_mode="rows", width="100%") + + @render.ui + def selected_invoice(): + """Render the currently selected invoice""" + selection = invoice_list.cell_selection()["rows"] + if len(selection) > 0: + selection = selection[0] + df = get_filtered_invoices().iloc[selection]["Link"] + root_dir = Path(config.get("paths").get("invoices_root_dir")) + with open(root_dir / df.attrs.get("href"), "r", encoding="utf8") as file: + html = file.read() + return ui.HTML(html) + return selection diff --git a/shiny_invoice/ui_new_invoice.py b/shiny_invoice/ui_new_invoice.py new file mode 100644 index 0000000..f77d158 --- /dev/null +++ b/shiny_invoice/ui_new_invoice.py @@ -0,0 +1,124 @@ +"""This module contains the ui and the server for creating a new invoice.""" +import datetime +import io +from pathlib import Path +from string import Template + +import pandas as pd +from shiny import module, ui, render, reactive + + +@module.ui +def new_invoice_ui(config): + invoice_defaults = config.get("invoice_defaults") + + return ui.layout_column_wrap( + ui.card( + ui.card_header("Invoice Details"), + ui.input_text(id="invoice_number", label="Invoice Number", value="1", width="100%"), + ui.input_date(id="created_at_date", label="Created At", width="100%"), + ui.output_ui(id="due_date_ui", width="100%"), + ui.input_text(id="introduction", label="Introduction", value="Dear Sir or Madam,", width="100%"), + ui.input_text_area( + id="recipient_address", + label="Recipient Address", + value=invoice_defaults.get("recipient"), + rows=3, + width="100%" + ), + ui.tooltip( + ui.input_text_area( + id="invoice_items", + label="Invoice Items", + value=invoice_defaults.get("items"), + rows=6, + width="100%", + spellcheck=True + ), + "Should be in csv format. The last column will be used to calculate the total price." + "The values should be before taxes." + ), + ui.download_button(id="download_button", label="Download Invoice", width="100%") + ), + ui.card( + ui.card_header("Rendered Invoice"), + ui.output_ui(id="rendered_invoice_ui", width="100%") + ) + ) + + +@module.server +def new_invoice_server(input, output, session, config): + + with open(Path(config.get("paths").get("html_template")), "r", encoding="utf8") as file: + html_template = Template(file.read()) + + @reactive.calc + def parse_invoice_items() -> pd.DataFrame: + return pd.read_csv(io.StringIO(input.invoice_items()), sep=",") + + @reactive.calc + def convert_invoice_csv_to_html() -> str: + return parse_invoice_items().to_html(index=False, border=0) + + @reactive.calc + def calculate_totals(): + items = parse_invoice_items() + last_column = items.columns[-1] + items[last_column] = items[last_column].str.replace(".", "").str.replace("€", "").astype(float) + return items[last_column].sum() + + @render.ui + def due_date_ui(): + payment_terms_days = config.get("company").get("payment_terms_days") + due_date = input.created_at_date() + datetime.timedelta(days=payment_terms_days) + return ui.input_date("due_date", "Due date", value=str(due_date), width="100%") + + @reactive.calc + def customer_name(): + return input.recipient_address().split("\n")[0] + + @render.download( + filename=lambda: f"{input.created_at_date()}-{input.invoice_number()}-{customer_name()}.html", + ) + def download_button(): + """Download the currently created invoice""" + with io.BytesIO() as buf: + buf.write(render_invoice().encode("utf8")) + yield buf.getvalue() + + @reactive.calc + def render_invoice(): + total_net = calculate_totals() + company = config.get("company") + tax = total_net * float(company.get("tax_rate")) + total_gross = total_net + tax + substitutions = { + "name": company.get("name"), + "primary_skills": " | ".join(company.get("skills")[:2]), + "all_skills": "
".join(company.get("skills")), + "piped_address": " | ".join(company.get("address")), + "linebreaked_address": "
".join(company.get("address")), + "primary_contact": "
".join(company.get("contact")[:2]), + "bank": company.get("bank").get("name"), + "iban": company.get("bank").get("iban"), + "bic": company.get("bank").get("bic"), + "tax_number": company.get("bank").get("tax_number"), + "tax_rate": f"{float(company.get('tax_rate')) * 100}%", + "all_contact": "
".join(company.get("contact")), + "invoice_number": input.invoice_number(), + "created_at_date": input.created_at_date().strftime("%d.%m.%Y"), + "due_at_date": input.due_date().strftime("%d.%m.%Y"), + "introduction": input.introduction(), + "recipient_address": "
".join(input.recipient_address().split("\n")), + "invoice_items": convert_invoice_csv_to_html(), + "total_net": f"{total_net:n} €", + "tax": f"{tax:n} €", + "total_gross": f"{total_gross:n} €" + } + return html_template.substitute(substitutions) + + @render.ui + def rendered_invoice_ui(): + """Render the currently configured invoice""" + return ui.HTML(render_invoice())