From 29421ecbb48450bf22e33bdcf57ee1de7412b0be Mon Sep 17 00:00:00 2001 From: Ekaitz Zarraga Date: Sun, 19 Jul 2020 22:52:08 +0200 Subject: first commit --- .gitignore | 1 + README.md | 22 +++++ fracture/__main__.py | 88 +++++++++++++++++++ fracture/db.py | 39 +++++++++ fracture/invoice.py | 232 +++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 382 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 fracture/__main__.py create mode 100644 fracture/db.py create mode 100644 fracture/invoice.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b6d0525 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# Fracture + +Fracture is an invoice registering system based on the command line. + +It makes a heavy use of `EDITOR` environment variable that lets you edit +invoices based on python's `ConfigParser` module that are later written in a +SQLite database. + +## Configuration + +Run `fracture configure` for configuration. It will automatically open a sample +configuration file for you. Once it's filled it will store the configuration +file in `$XDG_CONFIG_HOME/fracture/config` (`XDG_CONFIG_HOME` falls back to +`$HOME/.config`). + +## Storage + +Storage is done in the configuration directory (for the moment). + +If you want to create a new invoice run `fracture new` and it will open an +empty invoice in your `EDITOR` of choice. Fill it, save and it will create and +store the invoice in the database. diff --git a/fracture/__main__.py b/fracture/__main__.py new file mode 100644 index 0000000..71ca639 --- /dev/null +++ b/fracture/__main__.py @@ -0,0 +1,88 @@ +from invoice import Invoice, Tax +import db + +import argparse +import tempfile +import subprocess +import sqlite3 +from configparser import ConfigParser +import os +from os import path + + +def load_config(): + basedir = os.environ.get('XDG_CONFIG_HOME', None) or \ + path.join(os.environ["HOME"], ".config") + + basedir = path.join(basedir, "fracture") + confile = path.join(basedir, "config") + + conf = ConfigParser() + conf.read(confile) + + # SERIES + for k in conf["series"]: + Invoice.SERIES[int(k)] = conf["series"][k] + + if Invoice.SERIES == {}: + raise "Invoice series not configured correctly: no series found" + + # ID FORMAT + FORMAT = conf["invoice"].get("id_format", + '"%s/%s/%s" % (series, date.year, id)') + + def f(series, date, id): + return eval(FORMAT, None, {"series": series, "date": date, "id": id}) + + Invoice.ID_FORMAT = f + + # CURRENCY + Invoice.CURRENCY = conf["invoice"].get("currency", "€") + Invoice.CURRENCY_DECIMAL = conf["invoice"].getint("currency_decimal", 2) + + # INVOICE LEVEL TAXES (like IRPF in Spain) + tax = [] + for k in conf["taxes"]: + tax.append(Tax(k, conf.getfloat("taxes",k))) + Invoice.DEFAULT_TAXES = tuple(tax) + + # DATABASE + Invoice.DB_FILE = path.join(basedir, "invoice.db") + if not os.path.exists(Invoice.DB_FILE): + db.create(Invoice.DB_FILE) + +def call_editor(filename): + """ Edit filename with $EDITOR (fallback to Vim) """ + if not os.path.exists(filename): + raise FileNotFoundError("File not found: " + filename) + process = subprocess.Popen([os.environ.get("EDITOR", "vim"), filename],) + process.wait() + +def edit(contents): + """ Edit temporary file with initial `contents` and return it's edited + content """ + + with tempfile.NamedTemporaryFile(mode="w", delete=False) as t: + t.write(contents) + + call_editor(t.name) + + with open(t.name) as t: + edited_content = t.read() + + if os.path.exists(t.name): + os.remove(t.name) + + return edited_content + + +if __name__ == "__main__": + load_config() + + + # TODO + a = Invoice() + a.from_config( edit( a.to_config() )) + num = a.persist() + print(num) + edit(Invoice.load(num).to_config()) diff --git a/fracture/db.py b/fracture/db.py new file mode 100644 index 0000000..2611ee9 --- /dev/null +++ b/fracture/db.py @@ -0,0 +1,39 @@ +import sqlite3 + +def create(dbname): + conn = sqlite3.connect(dbname) + c = conn.cursor() + + c.execute('''CREATE TABLE products + ( id INTEGER PRIMARY KEY, + invoice_id INTEGER, + description TEXT NOT NULL, + units REAL NOT NULL, + price_unit REAL NOT NULL, + vat REAL, + FOREIGN KEY(invoice_id) REFERENCES invoices(id) + ON DELETE CASCADE )''') + + # date is %Y-%m-%d + c.execute('''CREATE TABLE invoices + ( id INTEGER PRIMARY KEY, + type TEXT NOT NULL, + series INTEGER NOT NULL, + date TEXT NOT NULL, + notes TEXT, + + customer_id TEXT, + customer_name TEXT, + customer_address TEXT + )''') + + c.execute('''CREATE TABLE taxes + ( name TEXT NOT NULL, + invoice_id INTEGER, + ratio REAL, + PRIMARY KEY(name, invoice_id) + FOREIGN KEY(invoice_id) REFERENCES invoices(id) + ON DELETE CASCADE )''') + + conn.commit() + conn.close() diff --git a/fracture/invoice.py b/fracture/invoice.py new file mode 100644 index 0000000..f0ab2f7 --- /dev/null +++ b/fracture/invoice.py @@ -0,0 +1,232 @@ +import sqlite3 +from datetime import date, datetime +from configparser import ConfigParser +import io + +class Tax: + def __init__(self, name="", ratio = 0.0): + self.name = name.upper() + self.ratio = ratio + +class Product: + def __init__(self, description, units=1.0, + price_unit=0.0, vat = 0.21): + if len(description) == 0: + raise ValueError("Product: No description provided") + self.description = description + self.units = units + self.price_unit = price_unit + self.vat = vat + +class Customer: + def __init__(self, name, id = "", address = ""): + if len(name) == 0: + raise ValueError("Customer: No name provided") + self.name = name + self.id = id + self.address = address + +class Invoice: + TYPES = {"sent", "received"} + SERIES = {} + ID_FORMAT = None + CURRENCY = None + CURRENCY_DECIMAL = 0 + DB_FILE = None + DEFAULT_TAXES = () + + DEFAULT_PRODUCT_DESC = "Empty Product" + DEFAULT_CUSTOMER_DESC = "Empty Customer" + + def __init__(self, type = None, + series = None, + idate = None, + notes= None, + products = None, + customers = None, + taxes = None): + # Initializes to empty state if not set + self.set_type(type or tuple(Invoice.TYPES)[0]) + self.id = None + self.set_series(series or tuple(Invoice.SERIES.keys())[0]) + self.date = idate or date.today() + self.notes = notes or "" + self.products = products or (Product(Invoice.DEFAULT_PRODUCT_DESC), + Product(Invoice.DEFAULT_PRODUCT_DESC)) + self.customer = customers or Customer(Invoice.DEFAULT_CUSTOMER_DESC) + self.taxes = taxes or Invoice.DEFAULT_TAXES + + def set_series(self, series): + if series not in Invoice.SERIES.keys(): + raise ValueError ("Not valid series for Invoice. Valid are %s" + % Invoice.SERIES) + self.series = series + + def set_type(self, type): + if type not in Invoice.TYPES: + raise ValueError ("Not valid type for Invoice. Valid are: %s" + % Invoice.TYPES) + self.type = type + + def dump(self): + print(self.format_id()) + + def format_id(self): + return Invoice.ID_FORMAT(self.series, self.date, self.id) + + def to_config(self): + strf = io.StringIO() + cfg = ConfigParser() + cfg["invoice"] = { + "series": self.series, + "date": self.date, + "type": self.type, + "notes": self.notes, + "customer-name": self.customer.name, + "customer-address": self.customer.address, + "customer-id": self.customer.id, + } + for i,p in enumerate(self.products): + cfg["product-"+str(i)] = { + "description": p.description, + "units": p.units, + "price_unit": p.price_unit, + "vat": p.vat + } + + cfg["taxes"] = { x.name: x.ratio for x in self.taxes } + + cfg.write(strf) + return strf.getvalue() + + + def from_config(self, config): + cfg = ConfigParser() + cfg.read_string(config) + + # PRODUCTS SECTIONS + self.products = () + for s in cfg.sections(): + if not s.startswith("product"): + continue + desc = cfg[s]["description"] + un = float(cfg[s]["units"]) + pu = float(cfg[s]["price_unit"]) + vat = float(cfg[s]["vat"]) + self.products += ( Product(desc,un,pu,vat) ,) + if cfg[s]["description"] == Invoice.DEFAULT_PRODUCT_DESC: + raise ValueError("Product name not set") + if len(self.products) == 0: + raise ValueError("No products assigned") + + # TAXES SECTION + self.taxes = () + if cfg.has_section("taxes"): + for x in cfg["taxes"]: + self.taxes += ( Tax(x, float(cfg["taxes"][x])) ,) + + # INVOICE SECTION + if not cfg.has_section("invoice"): + raise ValueError("[invoice] section needed") + i = cfg["invoice"] + self.set_series(int(i["series"])) + self.date = datetime.strptime(i["date"], "%Y-%m-%d").date() + self.notes = i["notes"] + self.set_type(i["type"]) + self.customer= Customer(i["customer-name"], + i["customer-address"], + i["customer-id"]) + if i["customer-name"] == Invoice.DEFAULT_CUSTOMER_DESC: + raise ValueError("Customer name not set") + + def persist(self): + conn = sqlite3.connect(Invoice.DB_FILE) + c = conn.cursor() + c.execute("""INSERT INTO invoices ( + type, + series, + date, + notes, + customer_id, + customer_name, + customer_address + ) VALUES (?,?,?,?,?,?,?)""", ( + self.type, + self.series, + self.date.strftime("%Y-%m-%d"), + self.notes, + self.customer.id, + self.customer.name, + self.customer.address + )) + self.id = c.lastrowid + for p in self.products: + c.execute("""INSERT INTO products ( + invoice_id, + description, + units, + price_unit, + vat + ) VALUES (?,?,?,?,?)""",( + self.id, + p.description, + p.units, + p.price_unit, + p.vat + )) + for x in self.taxes: + c.execute("""INSERT INTO taxes ( + invoice_id, + name, + ratio + ) VALUES (?,?,?)""", (self.id, x.name, x.ratio)) + + conn.commit() + conn.close() + return self.id + + def load(id): + with sqlite3.connect(Invoice.DB_FILE) as conn: + conn.row_factory = sqlite3.Row + + invoice = Invoice() + + c = conn.cursor() + + # PRODUCTS + products = () + c.execute("""SELECT * FROM products WHERE invoice_id = ?""", (id,)) + res = c.fetchone() + while res is not None: + desc = res["description"] + un = res["units"] + pu = res["price_unit"] + vat = res["vat"] + products += (Product(desc,un,pu,vat),) + res = c.fetchone() + + # TAXES + taxes = () + c.execute("""SELECT * FROM taxes WHERE invoice_id = ?""", (id,)) + res = c.fetchone() + while res is not None: + taxes += (Tax(res["name"], res["ratio"]) ,) + res = c.fetchone() + + # INVOICE + c.execute("""SELECT * FROM invoices WHERE id = ?""", (id,)) + res = c.fetchone() + return Invoice( + res["type"], + res["series"], + res["date"], + res["notes"], + products, + Customer( res["customer_name"], + res["customer_id"], + res["customer_address"] ), + taxes) + + def format(self): + # TODO + pass -- cgit v1.2.3