aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorTim <contact@bytim.eu>2026-02-22 15:25:50 +0100
committerTim <contact@bytim.eu>2026-02-22 15:25:50 +0100
commitf279e20468fb5323c33cbf43346c35ddef7f96e0 (patch)
treec488ee2791296917367f704524fa8e41a0b518ea /src
Initial commit
Diffstat (limited to 'src')
-rw-r--r--src/cashflow/core.clj14
-rw-r--r--src/cashflow/database.clj87
-rw-r--r--src/cashflow/frontend/navigation.clj13
-rw-r--r--src/cashflow/frontend/transactions/one_time.clj139
-rw-r--r--src/cashflow/frontend/transactions/recurring.clj90
-rw-r--r--src/cashflow/frontend/utils.clj32
-rw-r--r--src/cashflow/routes.clj28
-rw-r--r--src/cashflow/utils.clj24
8 files changed, 427 insertions, 0 deletions
diff --git a/src/cashflow/core.clj b/src/cashflow/core.clj
new file mode 100644
index 0000000..530af72
--- /dev/null
+++ b/src/cashflow/core.clj
@@ -0,0 +1,14 @@
+(ns cashflow.core
+ (:require [cashflow.routes :as croutes]
+ [dotenv :as env]
+ [org.httpkit.server :as http-server])
+ (:import java.lang.Integer)
+ (:gen-class))
+
+(defn -main [& args]
+ (let [port (-> (env/env "WEB_PORT")
+ (or "8080")
+ Integer/parseInt)])
+ (println "Starting web server at port 8080...")
+ (http-server/run-server croutes/ring-handler {:port 8080}))
+
diff --git a/src/cashflow/database.clj b/src/cashflow/database.clj
new file mode 100644
index 0000000..6daae1f
--- /dev/null
+++ b/src/cashflow/database.clj
@@ -0,0 +1,87 @@
+(ns cashflow.database
+ (:require [datalevin.core :as dcore]
+ [cashflow.frontend.utils :as cfutils]
+ [cashflow.utils :as cutils]
+ [clojure.string :as cstr])
+ (:import java.time.LocalDate))
+
+(def ^:private schema {:type {:db/valueType :db.type/keyword}
+ :user {:db/valueType :db.type/string} ; TODO: multi user
+ :description {:db/valueType :db.type/string}
+ :amount {:db/valueType :db.type/float}
+ :date {:db/valueType :db.type/string}
+ :from {:db/valueType :db.type/string}
+ :to {:db/valueType :db.type/string}
+ :month-interval {:db/valueType :db.type/bigint}})
+
+(def connection (delay (dcore/get-conn "./cashflow.db" schema)))
+
+(defn create-transaction!
+ "Stores one transaction `x` in the database."
+ [x]
+ (dcore/transact! @connection [x]))
+
+(defn list-transactions [type]
+ (->> (dcore/q '[:find (pull ?e [*])
+ :in $ ?type
+ :where [?e :type ?type]]
+ (dcore/db @connection) type)
+ (map first)))
+
+(defn list-one-time-transactions [year month]
+ (->> (list-transactions :one-time)
+ (filter (fn [transaction]
+ (let [transaction-date (LocalDate/parse (:date transaction))]
+ (and (= year (.getYear transaction-date))
+ (= month (.getMonthValue transaction-date))))))
+ (sort-by :date)))
+
+(defn list-recurring-transactions []
+ (let [today (LocalDate/now)]
+ (->> (list-transactions :recurring)
+ (sort-by (fn [transaction]
+ (let [from (try (LocalDate/parse (:from transaction)) (catch Exception _ nil))
+ to (try (LocalDate/parse (:from transaction)) (catch Exception _ nil))]
+ (cond
+ (or (and (nil? from) (nil? to))
+ (and (nil? from) (some? to) (.isBefore today to))
+ (and (some? to) (.isAfter today from))) 0
+ (try (.isBefore today from)
+ (catch Exception _ false)) 1
+ (try (.isAfter today to)
+ (catch Exception _ false)) 2)))))))
+
+(defn recurring-transactions-applying-total
+ ([date]
+ (->> (list-transactions :recurring)
+ (filter (fn [transaction]
+ (let [from (try (LocalDate/parse (:from transaction)) (catch Exception _ nil))
+ to (try (LocalDate/parse (:to transaction)) (catch Exception _ nil))]
+ (try (or (and (nil? from) (nil? to))
+ (and (nil? from) (or (.isAfter to date) (.isEqual to date)))
+ (and (nil? to) (or (.isBefore from date) (.isEqual from date)))
+ (and (or (.isAfter to date) (.isEqual to date))
+ (or (.isBefore from date) (.isEqual from date))))
+ (catch Exception _ false)))))
+ (map (fn [transaction] (/ (:amount transaction) (:month-interval transaction))))
+ (apply +)))
+ ([year month]
+ (recurring-transactions-applying-total (LocalDate/parse (str (cutils/string-min-length (str year) 4 "0" :before) "-"
+ (cutils/string-min-length (str month) 2 "0" :before) "-01")))))
+
+(defn one-time-transactions-total [year month]
+ (->> (list-one-time-transactions year month)
+ (map #(-> % first :amount))
+ (apply +)
+ (+ (recurring-transactions-applying-total year month))))
+
+(defn id->transaction [id]
+ (-> (dcore/q '[:find (pull ?id [*])
+ :in $ ?id
+ :where [?id]]
+ (dcore/db @connection) id)
+ first first))
+
+(defn delete-transaction! [id]
+ (dcore/transact! @connection [[:db/retractEntity id]]))
+
diff --git a/src/cashflow/frontend/navigation.clj b/src/cashflow/frontend/navigation.clj
new file mode 100644
index 0000000..4c04bfc
--- /dev/null
+++ b/src/cashflow/frontend/navigation.clj
@@ -0,0 +1,13 @@
+(ns cashflow.frontend.navigation)
+
+(defn gen [current-page]
+ [:nav {:class "tabbed"}
+ [:a {:class (when (= current-page :one-time) "active")
+ :_ "on click go to url /transactions/one-time/"}
+ [:i "attach_money"]
+ [:span "einmalige"]]
+ [:a {:class (when (= current-page :recurring) "active")
+ :_ "on click go to url /transactions/recurring/"}
+ [:i "calendar_month"]
+ [:span "wiederkehrende"]]])
+
diff --git a/src/cashflow/frontend/transactions/one_time.clj b/src/cashflow/frontend/transactions/one_time.clj
new file mode 100644
index 0000000..d658dad
--- /dev/null
+++ b/src/cashflow/frontend/transactions/one_time.clj
@@ -0,0 +1,139 @@
+(ns cashflow.frontend.transactions.one-time
+ (:require [ring.util.response :as ruresp]
+ [cashflow.utils :as cutils]
+ [cashflow.frontend.utils :as cfutils]
+ [cashflow.database :as cdatabase]
+ [clojure.string :as cstr]
+ [dotenv :as env]
+ [cashflow.frontend.navigation :as cfnavigation])
+ (:import java.lang.Integer
+ java.lang.Double
+ java.time.LocalDate))
+
+(defn- gen-table [year month]
+ [:div {:class ["scroll" "surface"] :id "transaction-table" :style {:height "30em"}}
+ [:table {:class ["stripes" "border" "large-space"]}
+ [:thead {:class "fixed"}
+ [:tr
+ [:th "Datum"]
+ [:th "Beschreibung"]
+ [:th "Betrag"]
+ [:th]]]
+ [:tbody
+ [:tr
+ [:td]
+ [:td "wiederkehrende Umsätze"]
+ [:td (cfutils/render-amount (cdatabase/recurring-transactions-applying-total year month))]]
+ (for [transaction (cdatabase/list-one-time-transactions year month)]
+ [:tr
+ [:td (cstr/join "." (-> transaction :from (cstr/split #"-") reverse))]
+ [:td (:description transaction)]
+ [:td (cfutils/render-amount (:amount transaction))]
+ [:td [:button {:class ["transparent" "circle"]
+ :hx-delete (str "/transactions/one-time/" (:db/id transaction) "/")
+ :hx-target "#transaction-table"
+ :hx-swap "outerHTML"}
+ [:i "delete"]]]])]
+ [:tfoot {:class "fixed"}
+ [:tr
+ [:th {:scope "row" :colspan "2"} "Gesamt: "]
+ [:td (-> (cdatabase/one-time-transactions-total year month) cfutils/render-amount)]]]]])
+
+(defn- gen-month-switcher [year month]
+ (let [actual-date (LocalDate/now)]
+ [:div {:style {:display :flex}
+ :_ "on change go to url `/transactions/one-time/${#month-switcher-year.value}/${#month-switcher-month.value}/`"}
+ [:p {:style {:margin-top :auto
+ :margin-right "1em"
+ :font-weight :bold}} "Monat: "]
+ [:div {:class ["field" "border" "small"]}
+ [:input {:id "month-switcher-month"
+ :type :number :value month}]]
+
+ [:p {:style {:margin-top :auto
+ :font-weight :bold
+ :margin-left "1em"
+ :margin-right "1em"}} "Jahr: "]
+ [:div {:class ["field" "border" "small"]}
+ [:input {:id "month-switcher-year"
+ :type :number :value year}]]
+ (if (or (not= (.getMonthValue actual-date) month)
+ (not= (.getYear actual-date) year))
+ [:button {:style {:margin-top :auto}
+ :_ "on click go to url /transactions/one-time/"}
+ [:i "today"]
+ [:span "Springe zum aktuellen Monat"]])]))
+
+(defn- gen [req]
+ (let [year-param (-> req
+ (get-in [:path-params :year])
+ (cutils/string-min-length 2 "0" :before))
+ month-param (-> req
+ (get-in [:path-params :month])
+ (cutils/string-min-length 2 "0" :before))
+ default-day (or (try (LocalDate/parse (str year-param "-" month-param "-01"))
+ (catch Exception _ nil))
+ (LocalDate/now))
+ year (.getYear default-day)
+ month (.getMonthValue default-day)]
+ (list [:h1 "cashflow - Umsätze"]
+ (cfnavigation/gen :one-time)
+ (gen-month-switcher year month)
+ (gen-table year month)
+ [:article {:style {:width :fit-content :margin "3em auto"}}
+ [:form {:class "add-transaction" :hx-post "/transactions/one-time/" :hx-target "#transaction-table" :hx-swap "outerHTML"}
+ [:div {:class ["field" "label" "round" "border"] :style {:width :fit-content}}
+ [:input {:type :date :placeholder " " :name "date" :value (str default-day)}]
+ [:label "Datum"]]
+ [:div {:class ["field" "label" "round" "border"] :style {:width :fit-content}}
+ [:input {:type :text :placeholder " " :name "description"}]
+ [:label "Beschreibung"]]
+ [:div {:class ["field" "label" "round" "border"] :style {:width :fit-content}}
+ [:input {:type :number :placeholder " " :step "0.01" :name "amount"}]
+ [:label "Betrag"]]
+ [:button {:class ["small-round" "fill"] :style {:margin-top "1em"} :type :submit} "Hinzufügen"]]])))
+
+(defn handle-get [req]
+ (->> req
+ gen
+ (cfutils/render-page "cashflow - eimalige Umsätze")
+ ruresp/response))
+
+(defn- table-response [year month]
+ (-> (gen-table year month)
+ cfutils/render-component
+ ruresp/response))
+
+(defn handle-post [req]
+ (let [params (:form-params req)
+ description (get params "description")
+ amount (-> params
+ (get "amount")
+ Double/parseDouble)
+ date (get params "date")
+ split-date (cstr/split date #"-")
+ year (-> split-date
+ first
+ Integer/parseInt)
+ month (-> split-date
+ second
+ Integer/parseInt)
+ day (-> split-date
+ last
+ Integer/parseInt)]
+ (cdatabase/create-transaction! {:type :month
+ :description description
+ :amount amount
+ :year year
+ :month month
+ :day day})
+ (table-response year month)))
+
+(defn handle-delete [req]
+ (let [id (-> req
+ (get-in [:path-params :id])
+ Integer/parseInt)
+ transaction (cdatabase/id->transaction id)]
+ (cdatabase/delete-transaction! id)
+ (table-response (:year transaction) (:month transaction))))
+
diff --git a/src/cashflow/frontend/transactions/recurring.clj b/src/cashflow/frontend/transactions/recurring.clj
new file mode 100644
index 0000000..2d85bf8
--- /dev/null
+++ b/src/cashflow/frontend/transactions/recurring.clj
@@ -0,0 +1,90 @@
+(ns cashflow.frontend.transactions.recurring
+ (:require [cashflow.frontend.utils :as cfutils]
+ [cashflow.database :as cdatabase]
+ [ring.util.response :as ruresp]
+ [clojure.string :as cstr]
+ [cashflow.frontend.navigation :as cfnavigation])
+ (:import java.time.LocalDate))
+
+(defn- gen-table []
+ [:div {:class ["scroll" "surface"] :id "transaction-table" :style {:height "30em"}}
+ [:table {:class ["stripes" "border" "large-space"]}
+ [:thead {:class "fixed"}
+ [:tr
+ [:th "Beschreibung"]
+ [:th "von"]
+ [:th "bis"]
+ [:th "Betrag"]
+ [:th "Interval"]
+ [:th]]]
+ [:tbody
+ (for [transaction (cdatabase/list-recurring-transactions)]
+ [:tr
+ [:td (:description transaction)]
+ [:td (cstr/join "." (-> transaction :from (cstr/split #"-") reverse))]
+ [:td (cstr/join "." (-> transaction :to (cstr/split #"-") reverse))]
+ [:td (cfutils/render-amount (:amount transaction))]
+ [:td (:month-interval transaction) " Monate"]
+ [:td [:button {:class ["transparent" "circle"]
+ :hx-delete (str "/transactions/recurring/" (:db/id transaction) "/")
+ :hx-target "#transaction-table"
+ :hx-swap "outerHTML"}
+ [:i "delete"]]]])]]])
+
+(defn- gen [_req]
+ (list [:h1 "cashflow - Umsätze"]
+ (cfnavigation/gen :recurring)
+ (gen-table)
+ [:article {:style {:width :fit-content :margin "3em auto"}}
+ [:form {:class "add-transaction" :hx-post "/transactions/recurring/" :hx-target "#transaction-table" :hx-swap "outerHTML"}
+ [:div {:class ["field" "label" "round" "border"] :style {:width :fit-content}}
+ [:input {:type :text :placeholder " " :name "description"}]
+ [:label "Beschreibung"]]
+ [:div {:class ["field" "label" "round" "border"] :style {:width :fit-content}}
+ [:input {:type :date :placeholder " " :name "from"}]
+ [:label "von"]]
+ [:div {:class ["field" "label" "round" "border"] :style {:width :fit-content}}
+ [:input {:type :date :placeholder " " :name "to"}]
+ [:label "bis"]]
+ [:div {:class ["field" "label" "round" "border"] :style {:width :fit-content}}
+ [:input {:type :number :placeholder " " :step "0.01" :name "amount"}]
+ [:label "Betrag"]]
+ [:div {:class ["field" "label" "round" "border"] :style {:width :fit-content}}
+ [:input {:type :number :placeholder " " :name "month-interval"}]
+ [:label "Interval (monate)"]]
+ [:button {:class ["small-round" "fill"] :style {:margin-top "1em"} :type :submit} "Hinzufügen"]]]))
+
+(defn handle-get [req]
+ (->> req
+ gen
+ (cfutils/render-page "cashflow - wiederkehrende Umsätze")
+ ruresp/response))
+
+(defn- table-response []
+ (-> (gen-table)
+ cfutils/render-component
+ ruresp/response))
+
+(defn handle-post [req]
+ (let [params (:form-params req)
+ description (get params "description")
+ from (get params "from")
+ to (get params "to")
+ amount (-> params (get "amount") Double/parseDouble)
+ month-interval (-> params (get "month-interval") Integer/parseInt bigint)]
+ (cdatabase/create-transaction! {:type :recurring
+ :description description
+ :amount amount
+ :from from
+ :to to
+ :month-interval month-interval})
+ (table-response)))
+
+(defn handle-delete [req]
+ (let [id (-> req
+ (get-in [:path-params :id])
+ Integer/parseInt)
+ transaction (cdatabase/id->transaction id)]
+ (cdatabase/delete-transaction! id)
+ (table-response)))
+
diff --git a/src/cashflow/frontend/utils.clj b/src/cashflow/frontend/utils.clj
new file mode 100644
index 0000000..25091a6
--- /dev/null
+++ b/src/cashflow/frontend/utils.clj
@@ -0,0 +1,32 @@
+(ns cashflow.frontend.utils
+ (:require [hiccup2.core :as html]
+ [dotenv :as env]))
+
+(defn render-component [component]
+ (-> component
+ html/html
+ str))
+
+(defn render-page [title & body]
+ (-> [:html
+ [:head
+ [:meta {:name "viewport" :content "width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"}]
+ [:meta {:charset "UTF-8"}]
+ [:title title]
+ [:link {:rel "stylesheet" :href "/static/style.css"}]
+ [:link {:rel "stylesheet" :href "/static/beer.css"}]
+ [:meta {:name "robots" :content "noindex,nofollow"}]]
+ (apply conj [:body] body [[:script {:src "/static/beer.js"}]
+ [:script {:src "/static/htmx.js"}]
+ [:script {:src "/static/hyperscript.js"}]])]
+ render-component))
+
+(defn render-amount [amount]
+ [:p {:style {:color (cond
+ (pos? amount) :limegreen
+ (neg? amount) :red
+ :else :orange)}}
+ (str (when (pos? amount) "+")
+ amount
+ (or (env/env "CONCURRENCY") "€"))])
+
diff --git a/src/cashflow/routes.clj b/src/cashflow/routes.clj
new file mode 100644
index 0000000..8c2dbc8
--- /dev/null
+++ b/src/cashflow/routes.clj
@@ -0,0 +1,28 @@
+(ns cashflow.routes
+ (:require [reitit.ring :as rring]
+ [ring.util.response :as ruresp]
+ [ring.middleware.params :as rmparams]
+ [cashflow.frontend.home :as cfhome]
+ [cashflow.frontend.transactions.one-time :as cftone-time]
+ [cashflow.frontend.transactions.recurring :as cftrecurring]))
+
+(def routes [["/static/*" (rring/create-resource-handler)]
+
+ ["/" {:get {:handler (fn [& _] (ruresp/redirect "/transactions/one-time/" 308))}}]
+
+ ["/transactions"
+ ["/one-time"
+ ["/" {:get {:handler cftone-time/handle-get}
+ :post {:handler cftone-time/handle-post}}]
+ ["/{year}/{month}/" {:get {:handler cftone-time/handle-get}}]
+ ["/{id}/" {:delete {:handler cftone-time/handle-delete}}]]
+ ["/recurring"
+ ["/" {:get {:handler cftrecurring/handle-get}
+ :post {:handler cftrecurring/handle-post}}]
+ ["/{id}/" {:delete {:handler cftrecurring/handle-delete}}]]]])
+
+(def ring-handler (-> routes
+ (rring/router {:middleware []})
+ rring/ring-handler
+ rmparams/wrap-params))
+
diff --git a/src/cashflow/utils.clj b/src/cashflow/utils.clj
new file mode 100644
index 0000000..abbd2f9
--- /dev/null
+++ b/src/cashflow/utils.clj
@@ -0,0 +1,24 @@
+(ns cashflow.utils
+ (:require [clojure.string :as cstr]))
+
+(defn- n*string [n string]
+ (when (string? string)
+ (->> (for [_ (range n)] string)
+ (apply str))))
+
+(defn string-min-length
+ "Returns string `s` when length of string is `min-length`, otherwise fills string up with a `filler` character, either before `s` if `position` :before or after `s` if `position` is :after."
+ [s min-length filler position]
+ (when (string? s)
+ (let [filler-string (-> (- min-length (.length s))
+ (n*string filler))]
+ (cond
+ (= position :before)
+ (str filler-string s)
+
+ (= position :after)
+ (str s filler-string)
+
+ :else
+ s))))
+