Tidy up, add tests

This commit is contained in:
Daniel O'Connell 2019-10-07 17:15:22 +02:00
parent 5bb673038d
commit 664098705b
9 changed files with 418 additions and 99 deletions

View File

@ -74,8 +74,7 @@ and a key providing the cost of the item. The price can be provided in one of th
to working out what the hourly rate should be in a given month and multiplying it by the number
of hours worked in that month
* :function - an S-expression describing how to calculate the net value. Only numbers, basic mathematical
operations (+, -, /, *) and timesheet specific variables are supported (:worked, :required,
:to, :from).
operations (+, -, /, *) and timesheet specific variables are supported (:worked, :required).
Examples:

40
src/invoices/calc.clj Normal file
View File

@ -0,0 +1,40 @@
(ns invoices.calc)
(defn round [val]
(double (/ (Math/round (* val 100.0)) 100)))
(defn vat [{netto :netto vat-level :vat}]
(if-not vat-level 0 (* netto (/ vat-level 100))))
(defn brutto [{netto :netto :as item}] (round (+ netto (vat item))))
(defn parse-custom
"Parse the given function definition and execute it with the given `worklog`."
[work-log func]
(cond
(and (list? func) (some #{(first func)} '(+ - * /))) (apply (resolve (first func))
(map (partial parse-custom work-log) (rest func)))
(some #{func} '(:worked :required)) (func work-log)
(or (list? func) (not (number? func))) (throw (IllegalArgumentException. (str "Invalid functor provided: " (first func))))
:else func))
(defn calc-part-time [{worked :worked total :required} {base :base per-day :per-day}]
(let [hourly (/ (* base 8) (* total per-day))]
(float (* hourly worked))))
(defn calc-hourly [{worked :worked} {hourly :hourly}]
(* worked hourly))
(defn calc-custom [worked {function :function}]
(parse-custom worked function))
(defn set-price
"Set the net price for the given item, calculating it from a worklog if applicable."
[worked item]
(cond
(contains? item :function) (assoc item :netto (calc-custom worked item))
(contains? item :hourly) (assoc item :netto (calc-hourly worked item))
(contains? item :base) (assoc item :netto (calc-part-time worked item))
(not (contains? item :netto)) (assoc item :netto 0)
:else item))

View File

@ -1,7 +1,9 @@
(ns invoices.core
(:require [invoices.pdf :as pdf]
[invoices.settings :refer [invoices]]
[invoices.jira :refer [prev-timesheet prev-month]]
[invoices.jira :refer [prev-timesheet]]
[invoices.time :refer [prev-month last-working-day date-applies?]]
[invoices.calc :refer [set-price]]
[clojure.tools.cli :refer [parse-opts]]
[clojure.string :as str]
[clojure.java.shell :refer [sh]]
@ -11,33 +13,6 @@
(defn invoice-number [when number]
(->> [(or number 1) (-> when .getMonthValue) (-> when .getYear)] (map str) (str/join "/")))
(defn parse-custom [work-log func]
(cond
(and (list? func) (some #{(first func)} '(+ - * /))) (apply (resolve (first func))
(map (partial parse-custom work-log) (rest func)))
(list? func) (throw (IllegalArgumentException. (str "Invalid functor provided: " (first func))))
(some #{func} '(:worked :required :to :from)) (func work-log)
:else func))
(defn calc-part-time [{worked :worked total :required} {base :base per-day :per-day}]
(let [hourly (/ (* base 8) (* total per-day))]
(float (* hourly worked))))
(defn calc-hourly [{worked :worked} {hourly :hourly}]
(* worked hourly))
(defn calc-custom [worked {function :function}]
(parse-custom worked function))
(defn set-price [worked item]
(cond
(contains? item :function) (assoc item :netto (calc-custom worked item))
(contains? item :hourly) (assoc item :netto (calc-hourly worked item))
(contains? item :base) (assoc item :netto (calc-part-time worked item))
(not (contains? item :netto)) (assoc item :netto 0)
:else item))
(defn send-email [to from {smtp :smtp} invoice]
(when (not-any? nil? [to from smtp invoice])
(->>
@ -62,17 +37,13 @@
(defn run-callbacks [invoice callbacks]
(doall (map (partial run-callback invoice) callbacks)))
(defn date-applies? [when {to :to from :from}]
(and (or (nil? to) (-> when .toString (compare to) (< 0)))
(or (nil? from) (-> when .toString (compare from) (>= 0)))))
(defn render-month [when {seller :seller buyer :buyer items :items creds :credentials font-path :font-path} number]
(pdf/render seller buyer
(->> items
(filter (partial date-applies? when))
(map (partial set-price (prev-timesheet when creds))))
(pdf/last-working-day when)
(last-working-day when)
(invoice-number when number)
font-path))

View File

@ -1,5 +1,6 @@
(ns invoices.jira
(:require [clj-http.client :as client]))
(:require [clj-http.client :as client]
[invoices.time :refer [last-day prev-month]]))
(defn tempo [{tempo-token :tempo-token} endpoint params]
(-> (str "https://api.tempo.io/core/3" endpoint)
@ -16,9 +17,6 @@
(defn timesheet [{spent :timeSpentSeconds required :requiredSeconds period :period}]
(merge {:worked (/ spent 3600) :required (/ required 3600)} period))
(defn last-day [when] (-> when (.withDayOfMonth 1) (.plusMonths 1) (.minusDays 1)))
(defn prev-month [when] (-> when (.withDayOfMonth 1) (.minusMonths 1)))
(defn get-timesheet [who when credentials]
(->> {"from" (-> when (.withDayOfMonth 1) .toString) "to" (-> when last-day .toString)}
(tempo credentials (str "/timesheet-approvals/user/" who))

View File

@ -1,17 +1,10 @@
(ns invoices.pdf
(:require [clj-pdf.core :refer [pdf]]
[invoices.time :refer [skip-days-off last-working-day]]
[invoices.calc :refer [brutto vat round]]
[clojure.string :as str])
(:import [java.awt Font]))
(defn round [val]
(float (/ (Math/round (* val 100.0)) 100)))
(defn vat [{netto :netto vat-level :vat}]
(if-not vat-level 0 (* netto (/ vat-level 100))))
(defn brutto [{netto :netto :as item}] (round (+ netto (vat item))))
(defn format-total [items getter]
[:cell {:background-color [216 247 249]}
(->> items (map getter) (reduce +) round str)])
@ -37,17 +30,16 @@
(str/replace #"[ -/]" "_"))))
(defn render [seller buyer items when number & [font-path]]
(let [title (get-title (:team seller) (:name seller) number)]
(println " -" title)
(pdf
(defn pdf-body
"Generate the actual pdf body"
[title seller buyer items when number font]
[{:title title
:right-margin 50
:author (:name seller)
:bottom-margin 10
:left-margin 10
:top-margin 20
:font (clojure.core/when font-path{:encoding :unicode :ttf-name font-path})
:font font
:size "a4"
:footer "page"}
@ -81,14 +73,12 @@
(format-total items :netto)
""
(format-total items vat)
(format-total items brutto)]])]
(format-total items brutto)]])])
(defn render [seller buyer items when number & [font-path]]
(let [title (get-title (:team seller) (:name seller) number)]
(println " -" title)
(pdf (pdf-body title seller buyer items when number (clojure.core/when font-path{:encoding :unicode :ttf-name font-path}))
(str title ".pdf"))
title))
(defn skip-days-off [when]
(if (some #{(.getDayOfWeek when)} [java.time.DayOfWeek/SATURDAY java.time.DayOfWeek/SUNDAY])
(skip-days-off (.minusDays when 1)) when))
(defn last-working-day [when]
(-> when (.withDayOfMonth 1) (.plusMonths 1) (.minusDays 1) skip-days-off))

27
src/invoices/time.clj Normal file
View File

@ -0,0 +1,27 @@
(ns invoices.time)
(defn last-day
"Get the last day of the month of the given `date`"
[date] (-> date (.withDayOfMonth 1) (.plusMonths 1) (.minusDays 1)))
(defn prev-month
"Get the first day of the month preceeding the given `date`"
[date] (-> date (.withDayOfMonth 1) (.minusMonths 1)))
(defn skip-days-off
"Return the first day before (and including) `date` that isn't a day off."
[date]
(if (some #{(.getDayOfWeek date)} [java.time.DayOfWeek/SATURDAY java.time.DayOfWeek/SUNDAY])
(skip-days-off (.minusDays date 1)) date))
(defn last-working-day
"Get the last working day of `date`'s month."
[date]
(-> date (.withDayOfMonth 1) (.plusMonths 1) (.minusDays 1) skip-days-off))
(defn date-applies?
"Return whether the provided `date` is between the provided :to and :from dates."
[date {to :to from :from}]
(and (or (nil? to) (-> date .toString (compare to) (< 0)))
(or (nil? from) (-> date .toString (compare from) (>= 0)))))

110
test/invoices/calc_test.clj Normal file
View File

@ -0,0 +1,110 @@
(ns invoices.calc-test
(:require [clojure.test :refer :all]
[invoices.calc :refer :all]))
(deftest test-round
(testing "Rounding to 2 decimal points"
(is (== (round 10) 10.0))
(is (== (round 10.1) 10.1))
(is (== (round 10.12) 10.12))
(is (== (round 10.123) 10.12))
(is (== (round 10.1234) 10.12)))
(testing "Rounding is correct"
(is (== (round 10.5) 10.5))
(is (== (round 10.119) 10.12))
(is (== (round 10.155) 10.15))
(is (== (round 10.1251) 10.13))))
(deftest test-vat
(testing "Check vat calculations"
(is (= (vat {:netto 1000 :vat 23}) 230))
(is (= (vat {:netto 1000 :vat 8}) 80))
(is (= (vat {:netto 1000 :vat 0}) 0))
(is (= (vat {:netto 0 :vat 23}) 0))
(is (= (vat {:netto 1000 :vat -8}) -80))))
(deftest test-brutto
(testing "Check whether calculating brutto works"
(is (= (brutto {:netto 1000 :vat 23}) 1230.0))
(is (= (brutto {:netto 1000 :vat 8}) 1080.0))
(is (= (brutto {:netto 1000 :vat 0}) 1000.0))
(is (= (brutto {:netto 0 :vat 23}) 0.0))
; negative VAT, coz why not?
(is (= (brutto {:netto 1000 :vat -23}) 770.0))))
(deftest test-parse-custom
(testing "Check that the specified operators are allowed"
(is (= (parse-custom {} '(+ 1 2)) 3))
(is (= (parse-custom {} '(- 1 2)) -1))
(is (= (parse-custom {} '(* 4 2)) 8))
(is (= (parse-custom {} '(/ 13 2)) 13/2)))
(testing "Check that non specified operators cause errors"
(is (thrown? java.lang.IllegalArgumentException (parse-custom {} '(> 1 2))))
(is (thrown? java.lang.IllegalArgumentException (parse-custom {} '(< 1 2))))
(is (thrown? java.lang.IllegalArgumentException (parse-custom {} '(map 1 2)))))
(testing "Check that worklog values get used"
(is (= (parse-custom {:worked 12} '(+ :worked 2)) 14))
(is (= (parse-custom {:required 32} '(- :required 2)) 30)))
(testing "Check error raised if non worklog keys provided"
(is (thrown? java.lang.IllegalArgumentException (parse-custom {} '(> :bla 2))))
(is (thrown? java.lang.IllegalArgumentException (parse-custom {} '(> :non-worked 2))))))
(deftest test-calc-part-time
(testing "Check whether calculating part time costs works"
; 4h per day, 10 per month if all hours done, the person did all of thier required hours
(is (= (calc-part-time {:worked 12 :required 24} {:base 10 :per-day 4}) 10.0))
; 4h per day, 10 per month if all hours done, the person is 2 hours low
(is (= (round (calc-part-time {:worked 10 :required 24} {:base 10 :per-day 4})) 8.33))
; 4h per day, 10 per month if all hours done, the person did 10h extra hours
(is (= (round (calc-part-time {:worked 22 :required 24} {:base 10 :per-day 4})) 18.33))
; 8h per day, 10 per month if all hours done, the person did all required hours
(is (= (round (calc-part-time {:worked 24 :required 24} {:base 10 :per-day 8})) 10.0))))
(deftest test-calc-hourly
(testing "Check whether calculating hourly rates works"
(is (= (calc-hourly {:worked 12} {:hourly 10}) 120))
(is (= (calc-hourly {:worked 12.5} {:hourly 10}) 125.0))
(is (= (calc-hourly {:worked 12} {:hourly 10.99}) 131.88))))
(deftest test-calc-custom
(testing "Check whether custom formulas work"
; base per hour
(is (= (calc-custom {:worked 12} {:function '(* :worked 10)}) 120))
; Sylwia's formula
(is (= (round (calc-custom {:worked 100 :required 168}
{:function '(+ 1000 (* (- :worked (/ :required 2)) (/ 2000 167)))}))
1191.62))))
(deftest test-set-price
(let [worked {:worked 100 :required 168}]
(testing "Check the default is to set 0"
(is (= (:netto (set-price worked {})) 0)))
(testing "Check that :netto is returned if no calc func provided"
(is (= (:netto (set-price worked {:netto 123})) 123)))
(testing "Check that :per-day is ignored if :base not provided"
(is (= (:netto (set-price worked {:per-day 123})) 0)))
(testing "Check that part time is calculated if :base provided"
(is (= (round (:netto (set-price worked {:base 100 :per-day 4}))) 119.05)))
(testing "Check that per hour calculated if :hourly provided"
(is (= (round (:netto (set-price worked {:hourly 10}))) 1000.0)))
(testing "Check that custom func used if provided"
(is (= (round (:netto (set-price worked {:function '(* :worked 12)}))) 1200.0)))))

108
test/invoices/pdf_test.clj Normal file
View File

@ -0,0 +1,108 @@
(ns invoices.pdf-test
(:require [clojure.test :refer :all]
[invoices.pdf :refer :all]))
(deftest test-format-total
(testing "Check whether the `total` cell gets correctly formatted"
(is (= (format-total [1 2 3 4] identity) [:cell {:background-color [216 247 249]} "10.0"])))
(testing "Check whether the `total` cell gets correctly formatted with accessor"
(is (= (format-total [] :netto) [:cell {:background-color [216 247 249]} "0.0"]))
(is (= (format-total [{:netto 12} {:netto 32}] :netto)
[:cell {:background-color [216 247 249]} "44.0"]))))
(deftest test-format-param
(testing "Check whether parameters get correctly formatted"
(is (= (format-param 123) [:cell.param {:align :right} "123: "]))
(is (= (format-param "bla") [:cell.param {:align :right} "bla: "]))))
(deftest test-format-value
(testing "Check whether values get correctly formatted"
(is (= (format-value 123) [:cell {:colspan 2} "123"]))
(is (= (format-value "bla") [:cell {:colspan 2} "bla"]))))
(deftest test-format-product
(testing "Check whether whole products get correctly formatted"
(is (= (format-product {:netto 1000 :vat 23 :title "bla bla"})
[[:cell {:colspan 4} "bla bla"] "1" "1000.0" "23%" "230.0" "1230.0"]))
(is (= (format-product {:netto 1000 :vat 0 :title "bla bla"})
[[:cell {:colspan 4} "bla bla"] "1" "1000.0" "0%" "0.0" "1000.0"])))
(testing "Check whether no vat is handled correctly"
(is (= (format-product {:netto 1000 :title "bla bla"})
[[:cell {:colspan 4} "bla bla"] "1" "1000.0" "zw." "0.0" "1000.0"]))))
(deftest test-get-title
(testing "Check whether getting titles works"
(is (= (get-title nil "mr blobby" "2019/02/11") "mr_blobby_luty_2019_02_11"))
(is (= (get-title "asd" "mr blobby" "2019/02/11") "asd_mr_blobby_luty_2019_02_11"))))
(deftest test-get-pdf
(testing "Check whether generating pdf bodies works"
(let [seller {:name "Mr. Blobby"
:address "ul. podwodna, 12-345, Mierzów"
:nip 1234567890
:phone 876543216
:account "65 2345 1233 1233 4322 3211 4567"
:bank "Skok hop"}
buyer {:name "Buty S.A."
:address "ul. Szewska 32, 76-543, Bąków"
:nip 9875645342}
items [{:vat 8 :netto 123.21 :title "Buty kowbojskie"}
{:netto 321.45 :title "Usługa szewska bez VAT"}]
date (java.time.LocalDate/parse "2018-03-02")
font "/usr/share/fonts/truetype/freefont/FreeSans.ttf"]
(is (= (pdf-body "pdf title" seller buyer items date 12 font)
[{:bottom-margin 10
:right-margin 50
:left-margin 10
:footer "page"
:font "/usr/share/fonts/truetype/freefont/FreeSans.ttf"
:size "a4"
:title "pdf title"
:author "Mr. Blobby"
:top-margin 20}
[:heading "Faktura"]
[:spacer]
[:paragraph "Nr 12"]
[:spacer 2]
[:table {:border false, :padding 0, :spacing 0, :num-cols 6}
[[:cell.param {:align :right} "sprzedawca: "]
[:cell {:colspan 2} "Mr. Blobby"]
[:cell.param {:align :right} "nabywca: "]
[:cell {:colspan 2} "Buty S.A."]]
[[:cell.param {:align :right} "adres: "]
[:cell {:colspan 2} "ul. podwodna, 12-345, Mierzów"]
[:cell.param {:align :right} "adres: "]
[:cell {:colspan 2} "ul. Szewska 32, 76-543, Bąków"]]
[[:cell.param {:align :right} "nip: "]
[:cell {:colspan 2} "1234567890"]
[:cell.param {:align :right} "nip: "]
[:cell {:colspan 2} "9875645342"]]
[[:cell.param {:align :right} "numer telefonu: "]
[:cell {:colspan 2} "876543216"]]]
[:spacer]
[:line]
[:table {:border false, :padding 0, :spacing 0, :num-cols 6}
[[:cell.param {:align :right} "data wystawienia: "]
[:cell {:colspan 2} "2018-03-02"]
[:cell.param {:align :right} "sposób płatności: "]
[:cell {:colspan 2} "Przelew"]]
[[:cell.param {:align :right} "data sprzedaży: "]
[:cell {:colspan 2} "2018-03-02"]
[:cell.param {:align :right} "bank: "]
[:cell {:colspan 2} "Skok hop"]]
[[:cell.param {:align :right} "termin płatności: "]
[:cell {:colspan 2} "2018-03-16"]
[:cell.param {:align :right} "numer konta: "]
[:cell {:colspan 2} "65 2345 1233 1233 4322 3211 4567"]]]
(list :table {:header [{:background-color [216 247 249]} "Lp." [:cell {:colspan 4} "Nazwa"] "Ilość" "Cena netto" "Stawka VAT" "Kwota VAT" "Wartość brutto"], :num-cols 10}
(list 1 [:cell {:colspan 4} "Buty kowbojskie"] "1" "123.21" "8%" "9.86" "133.07")
(list 2 [:cell {:colspan 4} "Usługa szewska bez VAT"] "1" "321.45" "zw." "0.0" "321.45")
[[:cell {:background-color [84 219 229], :colspan 5, :align :center} "Razem"]
[:cell {:background-color [216 247 249]} "2.0"]
[:cell {:background-color [216 247 249]} "444.66"]
""
[:cell {:background-color [216 247 249]} "9.86"]
[:cell {:background-color [216 247 249]} "454.52"]])])))))

View File

@ -0,0 +1,76 @@
(ns invoices.time-test
(:require [clojure.test :refer :all]
[invoices.time :refer :all]))
(deftest test-last-day
(testing "getting last day of month"
(doseq [[day last] [["2019-10-01" "2019-10-31"]
["2019-06-28" "2019-06-30"]
["2019-02-03" "2019-02-28"]
["2020-02-13" "2020-02-29"]]]
(is (= (last-day (java.time.LocalDate/parse day)) (java.time.LocalDate/parse last))))))
(deftest test-prev-month
(testing "getting previous month"
(doseq [[day previous] [["2019-11-01" "2019-10-01"]
["2019-07-28" "2019-06-01"]
["2019-03-03" "2019-02-01"]
["2020-01-13" "2019-12-01"]]]
(is (= (prev-month (java.time.LocalDate/parse day)) (java.time.LocalDate/parse previous))))))
(deftest test-skip-days-off
(testing "Check that work days are returned as is"
(doseq [day ["2019-10-04" ; friday
"2019-10-03" ; thursday
"2019-10-02"
"2019-10-01"
"2019-10-07"]] ; monday
(is (= (skip-days-off (java.time.LocalDate/parse day)) (java.time.LocalDate/parse day)))))
(testing "Check that weekends are skipped"
(doseq [[day friday] [["2019-10-06" "2019-10-04"] ; a sunday
["2019-10-05" "2019-10-04"]
["2019-09-01" "2019-08-30"]]] ; month ends are correctly handled
(is (= (skip-days-off (java.time.LocalDate/parse day)) (java.time.LocalDate/parse friday))))))
(deftest test-last-working-day
(testing "Check that getting the last working day works"
(doseq [[day last] [["2019-10-06" "2019-10-31"] ; skip forward to the end of the month
["2019-08-31" "2019-08-30"]]] ; go back to the last working day
(is (= (last-working-day (java.time.LocalDate/parse day)) (java.time.LocalDate/parse last))))))
(deftest test-date-applies
(testing "Check that all dates apply when no to or from provided"
(doseq [day ["1066-10-06" "2019-10-31" "2219-08-30"]]
(is (date-applies? (java.time.LocalDate/parse day) {:to nil :from nil}))))
(let [day (java.time.LocalDate/parse "2019-10-10")]
(testing "Check that only dates before :to are valid"
(is (date-applies? day {:to "2020-10-10" :from nil}))
(is (date-applies? day {:to "2019-10-11" :from nil}))
(is (not (date-applies? day {:to "2019-10-10" :from nil}))) ; the same day is deemed invalid
(is (not (date-applies? day {:to "2010-10-10" :from nil}))))
(testing "Check that only dates after :from are valid"
(is (date-applies? day {:to nil :from "2000-10-10"}))
(is (date-applies? day {:to nil :from "2019-10-09"}))
(is (date-applies? day {:to nil :from "2019-10-10"})) ; the same day is deemed valid
(is (not (date-applies? day {:to nil :from "2019-10-11"})))
(is (not (date-applies? day {:to nil :from "2219-10-11"}))))
(testing "Check that only dates between :to :from are valid"
(is (date-applies? day {:to "2020-10-10" :from "2000-10-10"}))
(is (date-applies? day {:to "2019-10-11" :from "2019-10-10"}))
(is (not (date-applies? day {:to "2019-10-10" :from "2019-10-10"})))
(is (not (date-applies? day {:to "2019-10-11" :from "2019-10-11"}))))
(testing ":from must be before :to"
(is (not (date-applies? day {:to "2019-10-12" :from "2020-10-10"})))
(is (not (date-applies? day {:to "2018-10-10" :from "2020-10-10"}))))))