Commit 28faaa21 authored by phil's avatar phil

getting ready for open sourcing

parent bd5892c2
......@@ -6,66 +6,58 @@
[cljs-time.format :as time]))
;; spec
(def match-iso-date (partial re-matches #"^\d{4}-\d\d-\d\d$"))
(def match-template (partial re-matches #".*\.hbs"))
(s/def ::type #{"plain" ;; Allgemeine Buchung
"opening" ;; Eröffnungsbilanz
"invoice" ;; Rechnung wurde gestellt
"settlement" ;; Rechnung wurde beglichen
"expense" ;; Ausgabe
"refund" ;; Rückerstattung
"reconciliation" ;; Ausgleichsbuchung
"salary" ;; Gehalt
"outlay" ;; Spesenabrechnung
"adminshare"}) ;; "200ok Sozialfaktor"
;; NOTE: This is a set of all known event types. New event types need
;; to be listed here otherwise they will result in an error. This is
;; an early guard against typos in the name of event types -
;; unfortunately not much more, so we might as well drop it entirely.
(s/def ::type #{"plain" ;; Allgemeine Buchung
"opening" ;; Eröffnungsbilanz
"invoice" ;; Rechnung wurde gestellt
"settlement" ;; Rechnung wurde beglichen
"expense" ;; Ausgabe
"refund" ;; Rückerstattung
"reconciliation" ;; Ausgleichsbuchung
"salary" ;; Gehalt
"outlay" ;; Spesenabrechnung
"redistribution"}) ;; "200ok Sozialfaktor"
(s/def ::date (s/or :date util/date?
:iso-string (s/and string? match-iso-date)))
(s/def ::property string?)
(s/def ::ignore-warnings (s/coll-of ::property))
(s/def ::event (s/keys :req-un [::type
::date]
:opt-un [::ignore-warnings]))
(s/def ::events (s/coll-of ::event))
;; transformer
(defn harmonize-date-field [field event]
(if-let [date (field event)]
(defn harmonize-date-field [field evt]
(if-let [date (field evt)]
(if (string? date)
;; NOTE don't use this, this does not return an instance of Date
;; (assoc event field (time/parse util/iso-formatter date))
(assoc event field (js/Date. date))
event)
event))
;; (assoc evt field (time/parse util/iso-formatter date))
(assoc evt field (js/Date. date))
evt)
evt))
(defn ignore-warning? [evt key]
(defn ignore-warning?
"Checks if `evt` has `:ignore-warnings` set for `key`."
[evt key]
(->> (get evt :ignore-warnings [])
(util/include? (name key))))
(defn harmonize [event]
(->> event
(defn harmonize [evt]
(->> evt
(harmonize-date-field :date)
;; TODO: remove, we don' use `:settled` anymore
(harmonize-date-field :settled)))
......@@ -75,9 +67,12 @@
(util/validate! spec x))
(defn add-iso-date [event]
(->> event
(defn add-iso-date
"Transformer that takes the value of `:date`, builds an iso-date
string from it and assoc's it as `:iso-date`."
[evt]
(->> evt
:date
cljs-time/date-time
(time/unparse util/iso-formatter)
(assoc* event :iso-date)))
(assoc* evt :iso-date)))
(ns easy.common.tax
(:require [cljs.spec.alpha :as s]
[easy.config :refer [config]]
[easy.util :as util :refer [assoc*]]
[easy.common :as common]))
;; specs
(s/def ::rate (s/and float? #(< 0 %) #(> 1 %)))
(s/def ::since util/date?)
(s/def ::until util/date?)
(s/def ::rate-entry (s/keys :req-un [::rate]
:opt-un [::since
::until]))
(s/def ::rates (s/coll-of ::rate-entry))
;; helpers
(defn lookup-rate
[key {:keys [date]}]
(->> @config
key
(common/validate! ::rates)
(filter #(or (nil? (:since %)) (>= date (:since %))))
(filter #(or (nil? (:until %)) (<= date (:until %))))
;; TODO: assert-exactly-one!
first
:rate))
;; transformers
(defn add-period
"The period is when the vat is due."
[evt]
(let [date (-> evt :date)
year (.getFullYear date)
semester (if (< (.getMonth date) 6) 1 2)
period (str year "-H" semester)]
(assoc* evt :period)))
;; TODO `config` should probably instead be named `state` or `data`
(ns easy.config
(:require [easy.util :as util]))
;; TODO: `config` should probably instead be named `environment`
(def default-config
"The easy config file is a YAML file with the following structure, e.g.
```
---
customers: customers.yml
templates:
ledger:
plain: vorlagen/plain.dat.hbs
expense: vorlagen/expense.dat.hbs
invoice: vorlagen/invoice.dat.hbs
settlement: vorlagen/settlement.dat.hbs
opening: vorlagen/opening.dat.hbs
refund: vorlagen/refund.dat.hbs
salary: vorlagen/salary.dat.hbs
redistribution: vorlagen/redistribution.dat.hbs
outlay: vorlagen/outlay.dat.hbs
reconciliation: vorlagen/reconciliation.dat.hbs
output:
overview: vorlagen/overview.txt.hbs
invoice:
report:
template: vorlagen/report.txt.hbs
latex:
template: vorlagen/invoice.tex.hbs
directory: 'kunden/{{customer.name}}/rechnungen'
filename: '{{iso-date}}_200ok_R-{{replace invoice-no \".\" \"_\"}}.tex'
```
"
{:customers "customers.yml"
:templates
{:ledger
......@@ -22,7 +53,8 @@
(defn load! []
;; TODO check if file exists
;; TODO: check if file exists, otherwise print a helpful error
;; message
(->> (util/slurp ".easy.yml")
util/parse-yaml
(swap! config util/deep-merge)))
(ns easy.core
"This is the entry point. The -main function gets called from lumo."
"This is the entry point. The -main function gets called from lumo.
This namespace also provides functions for all eas subcommands."
(:require [easy.util :as util]
[easy.config :as config :refer [config]]
[easy.customers :as customers]
......@@ -7,6 +8,16 @@
[easy.transform :refer [transform]]
[easy.overview :as overview]
[easy.invoice :as invoice]
[easy.common :as common]
[easy.common.invoice-no :as invoice-no]
[clojure.tools.cli :refer [parse-opts]]
[cljs.pprint :refer [pprint]]
[cljs.spec.alpha :as s]
[clojure.string :refer [join]]
;; NOTE: Even though we don't use any of the remaining
;; namespaces in this list, we nevertheless have to
;; require them here, otherwise they won't get loaded at
;; all.
easy.plain
easy.expense
easy.refund
......@@ -15,13 +26,7 @@
easy.salary
easy.outlay
easy.settlement
easy.adminshare
[easy.common :as common]
[easy.common.invoice-no :as invoice-no]
[clojure.tools.cli :refer [parse-opts]]
[cljs.pprint :refer [pprint]]
[cljs.spec.alpha :as s]
[clojure.string :refer [join]]))
easy.redistribution))
;; commands
......@@ -31,14 +36,15 @@
"Transforms all events, renders and prints their ledger
representation."
[events options]
;; TODO use this in every other subcommand as well
;; TODO: use the context building with bin-by in every other
;; subcommand as well
(let [context (util/bin-by (comp keyword :type) events)]
(->> events
(map invoice-no/unify)
;; transform all events within the `context`
(map (partial transform context))
;; filter to the events that belong to the year given with -y
;; TODO do not filter when year is not given
;; TODO: do not filter when year is not given
(filter #(.startsWith (:iso-date %) (:year options)))
(map templating/render-ledger)
(join "\n")
......@@ -127,8 +133,7 @@
:transform (transform! events options)
:validate (validate! events options)
:overview (overview! events options)
;; else
(do
(do ;; <- else
(println (str "Unknown command: " command))
(process.exit 1)))
;; all good, exit nicely
......@@ -141,8 +146,12 @@
["-n" "--no NUMBER" "Invoice No"]])
;; TODO get rid of the warning by using reader conditionals in
;; https://github.com/clojure/tools.cli/blob/master/src/main/clojure/clojure/tools/cli.cljc
;; TODO: get rid of the warning by using reader conditionals in
;;
;; https://github.com/clojure/tools.cli/blob/master/src/main/clojure/clojure/tools/cli.cljc
;;
;; Wouldn't this be a nice open source contribution? Achive some
;; laurels! Do it! Now!
(defn -main
"The main function which is called by lumo. It builds the environment
and reads the input, then dispatches to `run`."
......@@ -150,12 +159,9 @@
(let [cli (parse-opts args cli-options)
command (-> cli :arguments first keyword)
options (-> cli :options)
;; TODO build env here
;; TODO: check early if `command` was given
;; TODO: build env here, doesn't need to be an atom!
runner (partial run command options)]
;; TODO check if `command` was given
;; TODO build environment, this doesn't need to be an atom
(config/load!)
(swap! config assoc :options cli)
(swap! config assoc :customers (customers/load))
......@@ -163,5 +169,5 @@
(if-let [path (-> cli :options :input)]
;; read yaml for path then call `run`
(runner (util/slurp path))
;; read input from stdin then call `run`
;; else read input from stdin then call `run`
(read-stdin runner))))
(ns easy.customers
"An example customers file looks like this:
```
- name: vr
year: 2015
number: 3
address: |
Voice Republic Media AG
Langstr. 10
8004 Zürich
rate: 100
deadline: 14
contact: info
- name: sva
year: 2014
number: 2
rate: 100
address: |
SVA Zürich
Röntgenstrasse 17
Postfach
8087 Zürich
contact: phil
```"
(:require [cljs.spec.alpha :as s]
[clojure.pprint :refer [pprint]]
[clojure.string :refer [replace join]]
[easy.util :as util :refer [assoc*]]
[easy.config :refer [config]]))
;; ------------------------------------------------------------
;; spec
;; required
(s/def ::shortname string?) ;; TODO use regex to ensure that it can be used in a fs path
;; NOTE: minimum 3 maximum 10 characters is a sensible limits for
;; customers' shortnames
(def match-shortname (partial re-matches #"[a-z]{3,10}"))
;; spec - required
(s/def ::year pos-int?) ;; when they became a customer
(s/def ::number pos-int?)
(s/def ::address string?)
(s/def ::number pos-int?) ;; a unique customer number
(s/def ::address string?) ;; multiline postal address
(s/def ::shortname (s/and string? match-shortname))
;; optional
(s/def ::discount (s/and float? #(>= % 0) #(< % 100))) ;; in percentage
(s/def ::contact string?) ;; TODO make sure there is a file which is used for latex for it
(s/def ::rate float?) ;; hourly
(s/def ::address-for-latex string?) ;; where every newline is preceded by "\\"
;; spec - optional
(s/def ::discount (s/and float? #(>= % 0) #(< % 100))) ;; a general discount in percentage
(s/def ::contact string?)
(s/def ::rate float?) ;; the default hourly rate for a given customer
(s/def ::address-for-latex string?)
(s/def ::customer (s/keys :req-un [::name
::year
......@@ -31,43 +57,52 @@
(s/def ::customers (s/coll-of ::customer))
;; ------------------------------------------------------------
;; defaults
(def defaults
{:deadline 30
:discount 0})
(def merge-defaults
(partial merge defaults))
;; ------------------------------------------------------------
;; load
(defn- latex-line-breaks [s]
(replace s "\n" "\\\\\n"))
(defn- add-address-for-latex [customer]
(->> customer
:address
latex-line-breaks
(assoc* customer :address-for-latex)))
(defn add-year-number [customer]
(defn- add-year-number [customer]
(->> (map customer [:year :number])
(join "-")
(assoc* customer :year-number)))
(defn transform [customer]
(defn- transform [customer]
(-> customer
add-address-for-latex
add-year-number))
(defn load []
(->> @config
:customers
;; TODO: check if file exists, error otherwise
util/slurp
util/parse-yaml
(util/validate! ::customers)
merge-defaults
(map transform)
(util/validate! ::customers)))
(ns easy.expense
"An *expense* example:
```
- type: expense
account: Aufwand:6940-Bankspesen
payer: Joint
amount: 60
date: 2018-01-31
description: Bankgebühren
```"
(:require [cljs.spec.alpha :as s]
[cljs-time.format :as time]
[easy.util :as util :refer [assoc*]]
[easy.common :as common]
[easy.common.tax :as tax]
[easy.config :refer [config]]
[easy.transform :refer [transform]]))
;; spec
;; required
;; spec - required
(s/def ::type #{"expense"})
(s/def ::date util/date?)
(s/def ::amount float?)
(s/def ::payer string?)
(s/def ::account string?)
(s/def ::payer string?)
(s/def ::amount float?)
;; optional
;; spec - optional
(s/def ::description string?)
(s/def ::iso-date (s/and string? common/match-iso-date))
(s/def ::ledger-state #{"*"})
(s/def ::ledger-template (s/and string? common/match-template))
(s/def ::event (s/keys :req-un [::type
::date
::amount
::payer
::account]
:opt-un [::description]))
:opt-un [::description
::ledger-template]))
;; defaults
......@@ -44,12 +48,12 @@
(partial merge defaults))
;; transformer
;; transformers
(defn- add-respect-tax-rate [evt]
;; TODO unhardcode
(assoc* evt :respect-tax-rate 0.077))
(->> (tax/lookup-rate :respect-tax-rate evt)
(assoc* evt :respect-tax-rate)))
(defn- add-respect-tax-amount [evt]
......@@ -59,32 +63,6 @@
util/round-currency
(assoc* evt :respect-tax-amount)))
;; TODO refactor into a tax namespace, settlement has the same code
;; TODO rewrite in a way that it does not need to be adjusted for
;; every year
(defn add-tax-period
"The tax-period is when the vat is due."
[evt]
(->> (let [date (-> evt :date)]
(cond
(and (>= date (time/parse "2017-06-01"))
(<= date (time/parse "2017-12-31")))
"2017-H2"
(and (>= date (time/parse "2018-01-01"))
(<= date (time/parse "2018-05-31")))
"2018-H1"
(and (>= date (time/parse "2018-06-01"))
(<= date (time/parse "2018-12-31")))
"2018-H2"
(and (>= date (time/parse "2019-01-01"))
(<= date (time/parse "2019-05-31")))
"2019-H1"
(and (>= date (time/parse "2019-06-01"))
(<= date (time/parse "2019-12-31")))
"2019-H2"
:else "Unknown"))
(assoc* evt :tax-period)))
(defmethod transform :expense [_ event]
(-> event
......@@ -92,8 +70,7 @@
common/add-iso-date
add-respect-tax-rate
add-respect-tax-amount
add-tax-period
(assoc* :ledger-state "*") ;; always cleared
tax/add-period
(assoc* :ledger-template
(get-in @config [:templates :ledger :expense]))
(common/validate! ::event)))
(ns easy.invoice
"An *invoice* event looks like this:
```
- type: invoice
date: 2018-04-08
description: VR Development
settled: 2018-07-11
customer-id: 3
number: 5
version: 1
items:
- rate: 100.0
hours: 12.5
beneficiary: Alain
- rate: 100.0
hours: 19.25
beneficiary: Phil
```"
(:require [cljs.spec.alpha :as s]
[easy.util :as util :refer [assoc*]]
[easy.common :as common]
[easy.common.invoice-no :as invoice-no]
[easy.common.tax :as tax]
[easy.templating :as templating]
[easy.config :refer [config]]
[easy.transform :refer [transform]]
......@@ -13,20 +31,15 @@
[cljs-time.format :as time]))
;; spec
(def match-invoice-no (partial re-matches #"^\d+\.\d+\.\d+$"))
(def match-period (partial re-matches #"^\d{4}-(H|Q)\d$"))
;; required
;; spec - required
(s/def ::type #{"invoice"})
(s/def ::date util/date?)
(s/def ::items (s/coll-of ::item/item))
;; optional
;; spec - optional
(s/def ::iso-date (s/and string? common/match-iso-date))
(s/def ::deadline pos-int?) ;; in days
(s/def ::header string?)
......@@ -38,10 +51,7 @@
(s/def ::tax-win float?)
(s/def ::net-total float?)
(s/def ::gross-total float?)
(s/def ::tax-period (s/or :settled (s/and string? match-period)
:unsettled #{"Unsettled"}))
(s/def ::period (s/or :settled (s/and string? match-period)
:unsettled #{"Unsettled"}))
(s/def ::period (s/and string? match-period))
(s/def ::ledger-state #{"!" "*"})
(s/def ::ledger-template (s/and string? common/match-template))
(s/def ::latex-template (s/and string? common/match-template))
......@@ -69,7 +79,6 @@
::tax-win
::net-total
::gross-total
::tax-period
::period
::ledger-state
::ledger-template
......@@ -91,14 +100,14 @@
(partial merge defaults))
;; transformer
;; transformers
;; TODO add doc strings to all functions
;; TODO add pre conditions to all functions
;; TODO: add doc strings to all functions
;; TODO: add pre conditions to all functions
(defn lookup-customer [{id :customer-id :as event}]
(defn- lookup-customer [{id :customer-id :as event}]
(->> @config
:customers
(filter #(= id (:number %)))
......@@ -106,7 +115,7 @@
(assoc* event :customer)))
(defn resolve-settlement [{:keys [invoice-no] :as evt} ctx]
(defn- resolve-settlement [{:keys [invoice-no] :as evt} ctx]
(if (nil? ctx)
evt
(->> ctx
......@@ -116,95 +125,64 @@
(assoc* evt :settlement))))
;; TODO make the tax rate configurable via config
(defn tax-rate-in [evt]
(if (< (:date evt)
(time/parse "2018-01-01"))
0.08
0.077))
;; TODO make the tax rate configurable via config
(defn tax-rate-out [evt]
(let [date (:date evt)]
(cond
;; saldo pre 2018
(< date (time/parse "2018-01-01")) 0.061
;; TODO maybe, because we switched to effective
;; (> date (time/parse "2018-12-31")) 0.77
;; saldo from 2018
:else 0.059)))
(defn add-tax-rate-in [evt]
(->> (tax-rate-in evt)
(->> (tax/lookup-rate :vat-tax-rate-in evt)
(assoc* evt :tax-rate-in)))
(defn add-tax-rate-out [evt]
(->> (tax-rate-out evt)
(->> (tax/lookup-rate :vat-tax-rate-out evt)
(assoc* evt :tax-rate-out)))
(defn add-tax-in [evt]
(->> (:net-total evt)
(* (:tax-rate-in evt))
util/round-currency
(assoc* evt :tax-in)))
(defn add-tax-out [evt]
(->> (:gross-total evt)
(* (:tax-rate-out evt))
util/round-currency
(assoc* evt :tax-out)))
(defn add-tax-win [evt]
(->> (:tax-out evt)
(- (:tax-in evt))
util/round-currency
(assoc* evt :tax-win)))
(defn transform-items [evt]
(update evt :items (partial map item/transform)))
(defn add-net-total [evt]
(->> evt
:items
(map :amount)
(reduce +)
;; TODO calculate and subtract discount
;; TODO: calculate and subtract discount
util/round-currency
(assoc* evt :net-total)))
(defn add-gross-total [evt]
(->> (+ (:net-total evt)
(:tax-in evt))
util/round-currency
(assoc* evt :gross-total)))
(defn add-invoice-no [evt]
(->> [:customer-id :number :version]
(map evt)
(join ".")
(assoc* evt :invoice-no)))
;; TODO rewrite in a way that it does not need to be adjusted for
;; every year
(defn add-tax-period [evt]
(->> (if-let [settlement (-> evt :settlement)]
(let [date (-> settlement :date)]
(cond
(and (>= date (time/parse "2018-01-01"))
(<= date (time/parse "2018-05-31")))
"2018-H1"
(and (>= date (time/parse "2018-06-01"))
(<= date (time/parse "2018-12-31")))
"2018-H2"
(and (>= date (time/parse "2019-01-01"))
(<= date (time/parse "2019-05-31")))
"2019-H1"
(and (>= date (time/parse "2019-06-01"))
(<= date (time/parse "2019-12-31")))
"2019-H2"
:else "Unknown"))
"Unsettled")
(assoc* evt :tax-period)))