summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEkaitz Zarraga <ekaitz@elenq.tech>2020-07-19 22:52:08 +0200
committerEkaitz Zarraga <ekaitz@elenq.tech>2020-07-19 22:52:08 +0200
commit29421ecbb48450bf22e33bdcf57ee1de7412b0be (patch)
tree3ad560941af254529e863b42bce810d397d18d93
first commit
-rw-r--r--.gitignore1
-rw-r--r--README.md22
-rw-r--r--fracture/__main__.py88
-rw-r--r--fracture/db.py39
-rw-r--r--fracture/invoice.py232
5 files changed, 382 insertions, 0 deletions
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