From f279e20468fb5323c33cbf43346c35ddef7f96e0 Mon Sep 17 00:00:00 2001 From: Tim Date: Sun, 22 Feb 2026 15:25:50 +0100 Subject: Initial commit --- src/cashflow/core.clj | 14 +++ src/cashflow/database.clj | 87 ++++++++++++++ src/cashflow/frontend/navigation.clj | 13 +++ src/cashflow/frontend/transactions/one_time.clj | 139 +++++++++++++++++++++++ src/cashflow/frontend/transactions/recurring.clj | 90 +++++++++++++++ src/cashflow/frontend/utils.clj | 32 ++++++ src/cashflow/routes.clj | 28 +++++ src/cashflow/utils.clj | 24 ++++ 8 files changed, 427 insertions(+) create mode 100644 src/cashflow/core.clj create mode 100644 src/cashflow/database.clj create mode 100644 src/cashflow/frontend/navigation.clj create mode 100644 src/cashflow/frontend/transactions/one_time.clj create mode 100644 src/cashflow/frontend/transactions/recurring.clj create mode 100644 src/cashflow/frontend/utils.clj create mode 100644 src/cashflow/routes.clj create mode 100644 src/cashflow/utils.clj (limited to 'src/cashflow') 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)))) + -- cgit v1.2.3