Worklogs from emails

This commit is contained in:
Daniel O'Connell 2019-10-08 21:10:07 +02:00
parent c7c0dc92d7
commit 3c4974797e
8 changed files with 239 additions and 107 deletions

103
README.md
View File

@ -21,16 +21,16 @@ The following options are available:
## Config file ## Config file
The config file should be a EDN file containing a list of invoices. Each invoice can have the The config file should be a EDN file containing a list of invoices, seller info,
following keys: optional font info and optional worklogs info, e.g.:
* :seller - the seller's (i.e. the entity to be paid) information. This is required {:seller {(...)}
* :buyer - the buyer's (i.e. the entity that will pay) information. This is required :invoices [(...)]
* :items - a list of items to be paid for :font-path "/path/to/font"
* :credentials - (optional) JIRA and Tempo access credentials. These are needed if the price depends on tracked time :worklogs [(...)]}
* :font-path - (optional) the path to a font file, e.g. "/usr/share/fonts/truetype/freefont/FreeSans.ttf"
* :callbacks - (optional) a list of commands to be called with the resulting pdf file
`:font-path` should be the path to a font file, e.g. "/usr/share/fonts/truetype/freefont/FreeSans.ttf"
See `resources/config.edn` for an example configuration. See `resources/config.edn` for an example configuration.
### Seller ### Seller
@ -42,10 +42,17 @@ See `resources/config.edn` for an example configuration.
* :nip - (required) the NIP of the seller, e.g. 1234567890 * :nip - (required) the NIP of the seller, e.g. 1234567890
* :account - (required) the number of the account to which the payment should go, e.g. "12 4321 8765 1000 0000 1222 3212" * :account - (required) the number of the account to which the payment should go, e.g. "12 4321 8765 1000 0000 1222 3212"
* :bank - (required) the name of the bank in which the account is held, e.g. "Piggy bank" * :bank - (required) the name of the bank in which the account is held, e.g. "Piggy bank"
* :email - (optional) the email of the seller, e.g. "mr.blobby@bla.com". This is required if a confirmation email is to be sent
* :phone - (optional) the phone number of the seller 555333111 * :phone - (optional) the phone number of the seller 555333111
* :team - (optional) a team name, to be prepended to the name of the resulting pdf, e.g. "the A team" * :team - (optional) a team name, to be prepended to the name of the resulting pdf, e.g. "the A team"
### Invoices
Each invoice can have the following keys:
* :buyer - the buyer's (i.e. the entity that will pay) information. This is required
* :items - a list of items to be paid for
* :imap - (optional) email credentials. These are needed if a confirmation email is to be sent
* :callbacks - (optional) a list of commands to be called with the resulting pdf file
### Buyer ### Buyer
@ -77,6 +84,8 @@ The price can be provided in one of the following ways:
* :function - an S-expression describing how to calculate the net value. Only numbers, basic mathematical * :function - an S-expression describing how to calculate the net value. Only numbers, basic mathematical
operations (+, -, /, *) and timesheet specific variables are supported (:worked, :required). operations (+, -, /, *) and timesheet specific variables are supported (:worked, :required).
If the price is to be calculated on the basis of a worklog, add a `:worklog` key
and make sure the `:worklogs` section has an item that can be used to access the worklog.
Examples: Examples:
@ -84,42 +93,80 @@ Examples:
{:vat 8 :netto 600 :title "Shoes" :to "2019-05-30" :notes ["A note at the bottom"]} {:vat 8 :netto 600 :title "Shoes" :to "2019-05-30" :notes ["A note at the bottom"]}
; 12% VAT, and an hourly rate of 12, first appearing on 2019-07-01 ; 12% VAT, and an hourly rate of 12, first appearing on 2019-07-01
{:vat 12 :hourly 12 :title "Something worth 12/h" :from "2019-07-01"} {:vat 12 :hourly 12 :title "Something worth 12/h" :from "2019-07-01" :worklog "washed_dishes"}
; 23% VAT, working part time with a base salary of 5000 ; 23% VAT, working part time with a base salary of 5000
{:vat 23 :base 5000 :per-day 4 :title "Part time job at 5000"}] {:vat 23 :base 5000 :per-day 4 :title "Part time job at 5000" :worklog "cleaned_shoes"}]
; 23% VAT, with a custom function ; 23% VAT, with a custom function
{:vat 23 :function (* :worked (/ 10000 :required)) :title "Custom function"}] {:vat 23 :function (* :worked (/ 10000 :required)) :title "Custom function"} :worklog :from-jira]
### Credentials ### Worklogs
In the case of hourly rates or variable hours, the number of hours worked needs to be fetched In the case of hourly rates or variable hours, the number of hours worked needs to be fetched
from a time tracker. Which requires appropriate credentials. See from a time tracker. Which requires appropriate credentials, described in the
[Jira's](https://developer.atlassian.com/cloud/jira/platform/jira-rest-api-basic-authentication/) following sections. Apart from provider specific values, each credentials map
must contain a `:type` key that describes the provider, and a `:ids` list, which
should contain all worklog ids that can be found in the given worklog. These ids
are used to link worklog values with items via the `:worklog` key of items.
#### Jira
See [Jira's](https://developer.atlassian.com/cloud/jira/platform/jira-rest-api-basic-authentication/)
and [Tempo's](https://tempo-io.atlassian.net/wiki/spaces/KB/pages/199065601/How+to+use+Tempo+Cloud+REST+APIs) and [Tempo's](https://tempo-io.atlassian.net/wiki/spaces/KB/pages/199065601/How+to+use+Tempo+Cloud+REST+APIs)
documentation on how to get the appropriate tokens. Once the tokens are generated, the :credentials documentation on how to get the appropriate tokens. Once the tokens are generated, add an appropriate
should look like the following: worklog entry like the following:
:credentials {:tempo-token "5zq7zF9LADefEGAs12eDDas3FDttiM" :worklogs [{:type :jira
:jira-token "qypaAsdFwASasEddDDddASdC" :ids [:from-jira]
:jira-user "mr.blobby@boots.rs"} :tempo-token "5zq7zF9LADefEGAs12eDDas3FDttiM"
:jira-token "qypaAsdFwASasEddDDddASdC"
:jira-user "mr.blobby@boots.rs"}]
If a confirmation email is to be sent, a :smtp key must also be provided, e.g.: #### Emails
Emails with worklogs should be sent in a psudo csv format, seperated by `;` or
whitespace. Use the `:headers` key to describe what data is contained in each
column.
The emails are looked for in the `:folder` folder of the email account, and all
emails from `:from` (or anyone if `:from` is nil or missing) and with the subject
contining `:subject` formatted with the processed date.
:credentials {:smtp {:host "smtp.gmail.com" Assuming the processed date is 2012-12-12, and the following configuration is provided:
:user "mr.blobby@buty.sa"
:pass "asd;l;kjsdfkljld" :worklogs [{:type :imap
:ssl true}} :ids [:item1 :item2]
:folder "inbox"
:host "imap.gmail.com"
:user "mr.blobby@boots.rs"
:pass "lksjdfklsjdflkjw"
:from "hr@boots.rs"
:subject "'Hours 'YYYY-MM"
:headers [:id :worked]}]
if `hr@boots.rs` sends an email to the `inbox` folder of `mr.blobby@boots.rs`'s
email account with the title `Hours 2012-12` and the following contents (notice the underscores):
washed_dishes; 12
cleaned_shoes; 43
the following work logs will be found:
[{:id "washed_dished" :worked 12}
{:id "cleaned_shoes" :worked 43}]
## Confirmation emails ## Confirmation emails
Each invoice can also be sent via email to the appropriate seller. For this to work, both the seller Each invoice can also be sent via email to the appropriate seller. For this to work, the buyer must
and the buyer must have an :email key set and the credentials must contain a :smtp key with the have an :email key set and a :smtp key with the :smtp settings for the email server should be provided.
:smtp settings for the email server.
:invoices [{:buyer {(...) :email "accounting@boots.rs"}
(...)
:smtp {:host "smtp.gmail.com"
:user "mr.blobby@buty.sa"
:pass "asd;l;kjsdfkljld"
:ssl true}}]
## Callbacks ## Callbacks

View File

@ -1,4 +1,4 @@
(defproject invoices "0.1.0-SNAPSHOT" (defproject invoices "0.1.1"
:description "Generate invoices on the basis of a config file" :description "Generate invoices on the basis of a config file"
:url "https://github.com/mruwnik/invoices" :url "https://github.com/mruwnik/invoices"
:license {:name "EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0" :license {:name "EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0"

View File

@ -1,25 +1,33 @@
[{:seller {:name "Mr. Blobby" {:seller {:name "Mr. Blobby"
:email "mr.blobby@buty.sa" :email "mr.blobby@buty.sa"
:address "ul. podwodna, 12-345, Mierzów" :address "ul. podwodna, 12-345, Mierzów"
:nip 1234567890 :nip 1234567890
:phone 876543216 :phone 876543216
:account "65 2345 1233 1233 4322 3211 4567" :account "65 2345 1233 1233 4322 3211 4567"
:bank "Skok hop"} :bank "Skok hop"}
:buyer {:name "Buty S.A." :font-path "/usr/share/fonts/truetype/freefont/FreeSans.ttf"
:email "faktury@buty.sa" :invoices [{:buyer {:name "Buty S.A."
:address "ul. Szewska 32, 76-543, Bąków" :email "faktury@buty.sa"
:nip 9875645342} :address "ul. Szewska 32, 76-543, Bąków"
:items [{:vat 8 :netto 123.21 :title "Buty kowbojskie"} :nip 9875645342}
{:vat 21 :hourly 43.12 :title "Usługa szewska"} :items [{:vat 8 :netto 123.21 :title "Buty kowbojskie"}
{:netto 321.45 :title "Usługa szewska bez VAT"} {:vat 21 :hourly 43.12 :title "Usługa szewska" :worklog :from-jira}
{:netto 321.45 :title "Usługa szewska zwolniona z VAT" :notes ["Podstawa zwolnienia z VAT: art. 113 ust. 1 i 9 Ustawa o VAT"]} {:netto 321.45 :title "Usługa szewska bez VAT"}
{:vat 23 :function (* :worked (+ 1 2 3 (- 23 13))) :title "Pucowania obuwia"} {:netto 321.45 :title "Usługa szewska zwolniona z VAT" :notes ["Podstawa zwolnienia z VAT: art. 113 ust. 1 i 9 Ustawa o VAT"]}
{:vat 23 :base 4300.00 :per-day 4 :title "Praca za ladą"}] {:vat 23 :function (* :worked (+ 1 2 3 (- 23 13))) :title "Pucowania obuwia"}
:font-path "/usr/share/fonts/truetype/freefont/FreeSans.ttf" {:vat 23 :base 4300.00 :per-day 4 :title "Praca za ladą"}]
:credentials {:tempo-token "5zq7zF9LADefEGAs12eDDas3FDttiM" :smtp {:host "smtp.gmail.com" :user "mr.blobby@buty.sa" :pass "asd;l;kjsdfkljld" :ssl true}}]
:jira-token "qypaAsdFwASasEddDDddASdC" :worklogs [{:type :jira
:jira-user "mr.blobby@boots.rs" :ids [:from-jira]
:smtp {:host "smtp.gmail.com" :tempo-token "5zq7zF9LADefEGAs12eDDas3FDttiM"
:user "mr.blobby@buty.sa" :jira-token "qypaAsdFwASasEddDDddASdC"
:pass "asd;l;kjsdfkljld" :jira-user "mr.blobby@boots.rs"}
:ssl true}}}] {:type :imap
:ids [:item1 :item2]
:folder "inbox"
:host "imap.gmail.com"
:user "mr.blobby@boots.rs"
:pass "lksjdfklsjdflkjw"
:from "hr@boots.rs"
:subject "'Hours 'YYYY-MM"
:headers [:id :worked]}]}

View File

@ -1,10 +1,10 @@
(ns invoices.core (ns invoices.core
(:require [invoices.pdf :as pdf] (:require [invoices.pdf :as pdf]
[invoices.settings :refer [invoices]] [invoices.settings :refer [invoices]]
[invoices.timesheets :refer [prev-timesheet]] [invoices.timesheets :refer [timesheets]]
[invoices.time :refer [prev-month last-working-day date-applies?]] [invoices.time :refer [prev-month last-working-day date-applies?]]
[invoices.calc :refer [set-price]] [invoices.calc :refer [set-price]]
[invoices.email :refer [send-email]] [invoices.email :as email]
[clojure.tools.cli :refer [parse-opts]] [clojure.tools.cli :refer [parse-opts]]
[clojure.string :as str] [clojure.string :as str]
[clojure.java.shell :refer [sh]]) [clojure.java.shell :refer [sh]])
@ -25,26 +25,28 @@
(defn run-callbacks [invoice callbacks] (defn run-callbacks [invoice callbacks]
(doall (map (partial run-callback invoice) callbacks))) (doall (map (partial run-callback invoice) callbacks)))
(defn for-month [{seller :seller buyer :buyer smtp :smtp callbacks :callbacks :as invoice} when & [number font]]
(defn render-month [when {seller :seller buyer :buyer items :items creds :credentials font-path :font-path} number] (let [file (pdf/render invoice (last-working-day when) (invoice-number when number))]
(pdf/render seller buyer (email/send-invoice file (:email buyer) smtp)
(->> items
(filter (partial date-applies? when))
(map (partial set-price (prev-timesheet when creds))))
(last-working-day when)
(invoice-number when number)
font-path))
(defn for-month [when {seller :seller buyer :buyer creds :credentials callbacks :callbacks :as invoice} & [number]]
(let [file (-> when (render-month invoice number) (str ".pdf") java.io.File. .getAbsolutePath)]
(send-email (:email buyer) (:email seller) creds file)
(run-callbacks file callbacks))) (run-callbacks file callbacks)))
(defn get-invoices [nips config] (defn item-price [worklogs item]
(if (seq nips) (-> item :worklog (worklogs) (set-price item)))
(filter #(some #{(-> % :buyer :nip str)} nips) (invoices config))
(invoices config)))
(defn calc-prices [invoice worklogs]
(update invoice :items (partial map (partial item-price worklogs))))
(defn prepare-invoice [{seller :seller font :font-path} month worklogs invoice]
(-> invoice
(assoc :seller seller)
(assoc :font-path font)
(calc-prices worklogs)))
(defn process-invoices [{invoices :invoices :as config} month worklogs]
(let [invoices (map (partial prepare-invoice config month worklogs) invoices)]
(doseq [[i invoice] (map-indexed vector invoices)]
(for-month invoice month (inc i))
(println))))
(def cli-options (def cli-options
[["-n" "--number NUMBER" "Invoice number. In the case of multiple invoices, they will have subsequent numbers" [["-n" "--number NUMBER" "Invoice number. In the case of multiple invoices, they will have subsequent numbers"
@ -78,8 +80,10 @@
(:help options) (help summary) (:help options) (help summary)
errors (exit -1 (str/join "\n" errors)) errors (exit -1 (str/join "\n" errors))
(not= 1 (count arguments)) (exit -1 "No config file provided")) (not= 1 (count arguments)) (exit -1 "No config file provided"))
(println "Generating invoices") (println "Generating invoices")
(doseq [[i invoice] (map-indexed vector (get-invoices (:company options) (first arguments)))] (let [month (java.time.LocalDate/now)
(for-month (:when options) invoice (+ i (:number options))) config (-> "config.edn" (invoices month))
(println))) worklogs (timesheets month (:worklogs config))]
(process-invoices config month worklogs)))
(shutdown-agents)) (shutdown-agents))

View File

@ -7,18 +7,17 @@
[clojure-mail.folder :as folder] [clojure-mail.folder :as folder]
[clojure-mail.message :refer (read-message) :as mess])) [clojure-mail.message :refer (read-message) :as mess]))
(defn send-email [to from {smtp :smtp} invoice] (defn send-invoice [invoice to {from :user :as smtp}]
(when (not-any? nil? [to from smtp invoice]) (when (and (not-any? nil? [to from smtp invoice])
(->> (->>
(send-message smtp {:from from (send-message smtp {:from from
:to [to] :to [to]
:subject invoice :subject (.getName invoice)
:body [{:type :attachment :body [{:type :attachment
:content (java.io.File. (str invoice ".pdf")) :content (.getAbsolutePath invoice)
:content-type "application/pdf"}]}) :content-type "application/pdf"}]})
:error (= :SUCCESS) :error (= :SUCCESS)))
(println " - email sent: ")))) (println " - email sent to " to)))
(defn server-find-messages (defn server-find-messages
"Find all messages in the given folder, filtering them by subject and sender (use nil to ignore)." "Find all messages in the given folder, filtering them by subject and sender (use nil to ignore)."
@ -62,11 +61,15 @@
(defn zip-item [headers cell] (defn zip-item [headers cell]
(into (sorted-map) (map vector headers cell))) (into (sorted-map) (map vector headers cell)))
(defn parse-float [s]
(Float. (re-find #"[\d\.]+" s )))
(defn extract-items [headers message] (defn extract-items [headers message]
(->> message (->> message
mess/get-content mess/get-content
split-cells split-cells
(map (partial zip-item headers)))) (map (partial zip-item headers))
(map #(update % :worked parse-float))))
(defn get-worklogs (defn get-worklogs
"Get all worklogs for the given month from the given imap server." "Get all worklogs for the given month from the given imap server."

View File

@ -30,14 +30,21 @@
[:table {:border false :padding 0 :spacing 0} [[:phrase {:style :bold} "Uwagi:"]]] [:table {:border false :padding 0 :spacing 0} [[:phrase {:style :bold} "Uwagi:"]]]
(map vector notes))])) (map vector notes))]))
;;; Title helpers
(def month-names
["" "styczen" "luty" "marzec" "kwiecien" "maj" "czerwiec" "lipiec" "sierpien" "wrzesien" "pazdziernik" "listopad" "grudzien"])
(defn get-title [team who which] (defn month-name [month]
(let [[nr month year] (-> which (str/split #"/")) (->> (str/split month #"/") second (Integer/parseInt) (nth month-names)))
months ["" "styczen" "luty" "marzec" "kwiecien" "maj" "czerwiec" "lipiec"
"sierpien" "wrzesien" "pazdziernik" "listopad" "grudzien"]]
(-> (str/join "_" (remove nil? [team who (nth months (Integer/parseInt month)) which]))
(str/replace #"[ -/]" "_"))))
(defn title-base [{{team :team who :name} :seller title :title}]
(if title title (->> [team who] (remove nil?) (str/join "_"))))
(defn get-title [item which]
(->> [(title-base item) (month-name which) which]
(map #(str/replace % #"[ -/]" "_"))
(str/join "_")))
;;;
(defn pdf-body (defn pdf-body
"Generate the actual pdf body" "Generate the actual pdf body"
@ -90,11 +97,11 @@
(->> items (map :notes) (remove nil?) flatten distinct format-notes (conj body))) (->> items (map :notes) (remove nil?) flatten distinct format-notes (conj body)))
(defn render [seller buyer items when number & [font-path]] (defn render [{seller :seller buyer :buyer items :items font-path :font-path :as invoice} when number]
(let [title (get-title (:team seller) (:name seller) number)] (let [title (get-title invoice number)]
(println " -" title) (println " - rendering" title)
(-> title (-> title
(pdf-body seller buyer items when number (clojure.core/when font-path {:encoding :unicode :ttf-name font-path})) (pdf-body seller buyer items when number (clojure.core/when font-path {:encoding :unicode :ttf-name font-path}))
(add-notes items) (add-notes items)
(pdf (str title ".pdf"))) (pdf (str title ".pdf")))
title)) (-> title (str ".pdf") java.io.File.)))

View File

@ -1,9 +1,55 @@
(ns invoices.settings (ns invoices.settings
(:require [clojure.edn :as edn])) (:require [clojure.edn :as edn]
[clojure.set :as set]
[invoices.time :refer [date-applies?]]))
(defn load-config (defn load-config
"Given a filename, load & return a config file" "Given a filename, load & return a config file"
[filename] [filename]
(edn/read-string (slurp filename))) (-> filename slurp edn/read-string))
(defn invoices [config] (load-config config)) (defn filter-invoice-nips
"Remove all invoices that don't apply to the given list of seller NIPs."
[{invoices :invoices :as config} nips]
(if (seq nips)
(->> invoices
(filter #(some #{(-> % :buyer :nip str)} nips))
(assoc config :invoices))
config))
(defn filter-unused-items
"Remove all items from the given invoice that don't match the date."
[{items :items :as invoice} date]
(->> items
(filter (partial date-applies? date))
(assoc invoice :items)))
(defn filter-invoice-dates
"Remove all items that don't apply to the given date, and also any with no items."
[{invoices :invoices :as config} date]
(->> invoices
(map #(filter-unused-items % date))
(filter (comp seq :items))
(assoc config :invoices)))
(defn used-worklogs
"Return all worklog ids used by the given invoices."
[invoices]
(->> invoices
(map #(->> % :items (map :worklog)))
flatten distinct set))
(defn filter-worklogs
"Remove any worklogs that aren't used by the given invoices."
[{invoices :invoices worklogs :worklogs :as config}]
(->> worklogs
(filter (comp seq #(set/intersection % (used-worklogs invoices)) set :ids))
(assoc config :worklogs)))
(defn invoices
"Get all invoices that apply to the given month and (optional NIPs)."
[config month & [nips]]
(-> config load-config
(filter-invoice-nips nips)
(filter-invoice-dates month)
filter-worklogs))

View File

@ -1,5 +1,6 @@
(ns invoices.timesheets (ns invoices.timesheets
(:require [clj-http.client :as client] (:require [clj-http.client :as client]
[invoices.email :as email]
[invoices.time :refer [last-day prev-month]])) [invoices.time :refer [last-day prev-month]]))
(defn tempo [{tempo-token :tempo-token} endpoint params] (defn tempo [{tempo-token :tempo-token} endpoint params]
@ -17,13 +18,29 @@
(defn timesheet [{spent :timeSpentSeconds required :requiredSeconds period :period}] (defn timesheet [{spent :timeSpentSeconds required :requiredSeconds period :period}]
(merge {:worked (/ spent 3600) :required (/ required 3600)} period)) (merge {:worked (/ spent 3600) :required (/ required 3600)} period))
(defn get-timesheet [who when credentials] (defn jira-timesheet [who when credentials]
(->> {"from" (-> when (.withDayOfMonth 1) .toString) "to" (-> when last-day .toString)} (let [log (->> {"from" (-> when (.withDayOfMonth 1) .toString) "to" (-> when last-day .toString)}
(tempo credentials (str "/timesheet-approvals/user/" who)) (tempo credentials (str "/timesheet-approvals/user/" who))
(timesheet))) (timesheet))]
(map (partial assoc log :id) (:ids credentials))))
(defn prev-timesheet (defn prev-timesheet
"Get the timesheet for the previous month" "Get the timesheet for the previous month"
[when credentials] [when credentials]
(clojure.core/when (:jira-user credentials) (clojure.core/when (:jira-user credentials)
(get-timesheet (me credentials) (prev-month when) credentials))) (jira-timesheet (me credentials) (prev-month when) credentials)))
(defn get-timesheet [month {type :type :as creds}]
(condp = type
:jira (jira-timesheet (me creds) month creds)
:imap (email/get-worklogs month creds)))
(defn timesheets
"Return timesheets for the given month from the given worklogs."
[month worklogs]
(->> worklogs
(map (partial get-timesheet month))
flatten
(map (fn [{id :id :as log}] [id log]))
(into {})))