diff --git a/resources/migrations/20260307000000-create-wallets.down.sql b/resources/migrations/20260307000000-create-wallets.down.sql new file mode 100644 index 0000000..9e04aee --- /dev/null +++ b/resources/migrations/20260307000000-create-wallets.down.sql @@ -0,0 +1 @@ +DROP TABLE wallets; diff --git a/resources/migrations/20260307000000-create-wallets.up.sql b/resources/migrations/20260307000000-create-wallets.up.sql new file mode 100644 index 0000000..7a86481 --- /dev/null +++ b/resources/migrations/20260307000000-create-wallets.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE wallets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +--;; +INSERT INTO wallets (name) VALUES + ('Crypto.Com'), ('Nexo'), ('CoinCorner'), ('Strike'), + ('Cold'), ('Coldcard'), ('Nunchuk Multi Sig'); diff --git a/resources/migrations/20260307100000-create-events.down.sql b/resources/migrations/20260307100000-create-events.down.sql new file mode 100644 index 0000000..5ec743c --- /dev/null +++ b/resources/migrations/20260307100000-create-events.down.sql @@ -0,0 +1 @@ +DROP TABLE events; diff --git a/resources/migrations/20260307100000-create-events.up.sql b/resources/migrations/20260307100000-create-events.up.sql new file mode 100644 index 0000000..14dea94 --- /dev/null +++ b/resources/migrations/20260307100000-create-events.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE events ( + id BIGSERIAL PRIMARY KEY, + event_type TEXT NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + sats BIGINT, + fee_sats BIGINT, + fiat_amount NUMERIC(18,2), + fiat_currency TEXT, + from_wallet_id UUID REFERENCES wallets(id), + to_wallet_id UUID REFERENCES wallets(id), + note TEXT +); +--;; +CREATE INDEX idx_events_type ON events (event_type); +--;; +CREATE INDEX idx_events_occurred ON events (occurred_at); diff --git a/resources/queries.sql b/resources/queries.sql index ed30895..57d5f30 100644 --- a/resources/queries.sql +++ b/resources/queries.sql @@ -65,3 +65,74 @@ SELECT rate_date, eur, dkk, eur_dkk, updated_at FROM currencies ORDER BY rate_da -- :name get-currency-rates-by-date :? :1 -- :doc Get currency rates for a specific date SELECT rate_date, eur, dkk, eur_dkk, updated_at FROM currencies WHERE rate_date = :rate-date + +-- Wallets ------------------------------------------------------------------- + +-- :name insert-wallet! :! :n +-- :doc Insert a new wallet +INSERT INTO wallets (name) VALUES (:name) + +-- :name get-all-wallets :? :* +-- :doc Get all wallets +SELECT id, name, created_at FROM wallets ORDER BY name + +-- :name get-wallet-by-id :? :1 +-- :doc Get a wallet by ID +SELECT id, name, created_at FROM wallets WHERE id = :id + +-- Events -------------------------------------------------------------------- + +-- :name insert-event! :! :n +-- :doc Insert a new event +INSERT INTO events (event_type, occurred_at, sats, fee_sats, fiat_amount, fiat_currency, from_wallet_id, to_wallet_id, note) +VALUES (:event-type, :occurred-at, :sats, :fee-sats, :fiat-amount, :fiat-currency, :from-wallet-id, :to-wallet-id, :note) + +-- :name get-all-events :? :* +-- :doc Get all events with wallet names +SELECT e.id, e.event_type, e.occurred_at, e.recorded_at, + e.sats, e.fee_sats, e.fiat_amount, e.fiat_currency, + e.from_wallet_id, fw.name AS from_wallet_name, + e.to_wallet_id, tw.name AS to_wallet_name, + e.note +FROM events e +LEFT JOIN wallets fw ON fw.id = e.from_wallet_id +LEFT JOIN wallets tw ON tw.id = e.to_wallet_id +ORDER BY e.occurred_at DESC + +-- :name get-events-by-type :? :* +-- :doc Get events filtered by type +SELECT e.id, e.event_type, e.occurred_at, e.recorded_at, + e.sats, e.fee_sats, e.fiat_amount, e.fiat_currency, + e.from_wallet_id, fw.name AS from_wallet_name, + e.to_wallet_id, tw.name AS to_wallet_name, + e.note +FROM events e +LEFT JOIN wallets fw ON fw.id = e.from_wallet_id +LEFT JOIN wallets tw ON tw.id = e.to_wallet_id +WHERE e.event_type = :event-type +ORDER BY e.occurred_at DESC + +-- :name get-events-by-wallet :? :* +-- :doc Get events involving a specific wallet +SELECT e.id, e.event_type, e.occurred_at, e.recorded_at, + e.sats, e.fee_sats, e.fiat_amount, e.fiat_currency, + e.from_wallet_id, fw.name AS from_wallet_name, + e.to_wallet_id, tw.name AS to_wallet_name, + e.note +FROM events e +LEFT JOIN wallets fw ON fw.id = e.from_wallet_id +LEFT JOIN wallets tw ON tw.id = e.to_wallet_id +WHERE e.from_wallet_id = :wallet-id OR e.to_wallet_id = :wallet-id +ORDER BY e.occurred_at DESC + +-- :name get-wallet-balances :? :* +-- :doc Compute sats balance per wallet from events +SELECT w.id, w.name, + COALESCE(SUM(CASE WHEN e.to_wallet_id = w.id THEN e.sats ELSE 0 END), 0) + - COALESCE(SUM(CASE WHEN e.from_wallet_id = w.id THEN e.sats ELSE 0 END), 0) + - COALESCE(SUM(CASE WHEN e.from_wallet_id = w.id THEN e.fee_sats ELSE 0 END), 0) + AS balance_sats +FROM wallets w +LEFT JOIN events e ON e.from_wallet_id = w.id OR e.to_wallet_id = w.id +GROUP BY w.id, w.name +ORDER BY w.name diff --git a/src/clj/pmagnus/btcdata/web/controllers/transactions.clj b/src/clj/pmagnus/btcdata/web/controllers/transactions.clj new file mode 100644 index 0000000..e903ab6 --- /dev/null +++ b/src/clj/pmagnus/btcdata/web/controllers/transactions.clj @@ -0,0 +1,50 @@ +(ns pmagnus.btcdata.web.controllers.transactions + (:require + [clojure.string :as str] + [ring.util.http-response :as response]) + (:import + [java.time Instant] + [java.util UUID])) + +(defn list-wallets [{:keys [query-fn]} _req] + (response/ok (query-fn :get-all-wallets {}))) + +(defn create-wallet! [{:keys [query-fn]} req] + (let [{:keys [name]} (:body-params req)] + (if (str/blank? name) + (response/bad-request {:error "name is required"}) + (do (query-fn :insert-wallet! {:name name}) + (response/created "/api/wallets" {:name name}))))) + +(defn get-wallet-balances [{:keys [query-fn]} _req] + (response/ok (query-fn :get-wallet-balances {}))) + +(defn create-event! [{:keys [query-fn]} req] + (let [params (:body-params req) + event-type (:event_type params)] + (if-not (#{"bank_to_exchange" "exchange_to_wallet" "wallet_to_wallet"} event-type) + (response/bad-request {:error "Invalid event_type"}) + (let [occurred-at (if-let [ts (:occurred_at params)] + (Instant/parse ts) + (Instant/now)) + row {:event-type event-type + :occurred-at occurred-at + :sats (:sats params) + :fee-sats (:fee_sats params) + :fiat-amount (:fiat_amount params) + :fiat-currency (:fiat_currency params) + :from-wallet-id (some-> (:from_wallet_id params) UUID/fromString) + :to-wallet-id (some-> (:to_wallet_id params) UUID/fromString) + :note (:note params)}] + (query-fn :insert-event! row) + (response/created "/api/events" {:status "ok"}))))) + +(defn list-events [{:keys [query-fn]} req] + (let [event-type (get-in req [:query-params "type"])] + (if event-type + (response/ok (query-fn :get-events-by-type {:event-type event-type})) + (response/ok (query-fn :get-all-events {}))))) + +(defn get-wallet-events [{:keys [query-fn]} req] + (let [id (UUID/fromString (get-in req [:path-params :id]))] + (response/ok (query-fn :get-events-by-wallet {:wallet-id id})))) diff --git a/src/clj/pmagnus/btcdata/web/middleware/core.clj b/src/clj/pmagnus/btcdata/web/middleware/core.clj index ea0a908..a760f1f 100644 --- a/src/clj/pmagnus/btcdata/web/middleware/core.clj +++ b/src/clj/pmagnus/btcdata/web/middleware/core.clj @@ -9,7 +9,7 @@ (if (= :options (:request-method request)) {:status 204 :headers {"Access-Control-Allow-Origin" origin - "Access-Control-Allow-Methods" "GET, OPTIONS" + "Access-Control-Allow-Methods" "GET, POST, DELETE, OPTIONS" "Access-Control-Allow-Headers" "Content-Type" "Access-Control-Max-Age" "86400"}} (when-let [resp (handler request)] diff --git a/src/clj/pmagnus/btcdata/web/routes/api.clj b/src/clj/pmagnus/btcdata/web/routes/api.clj index f217338..1da7611 100644 --- a/src/clj/pmagnus/btcdata/web/routes/api.clj +++ b/src/clj/pmagnus/btcdata/web/routes/api.clj @@ -4,6 +4,7 @@ [integrant.core :as ig] [pmagnus.btcdata.frankfurter.rates :as rates] [pmagnus.btcdata.web.controllers.health :as health] + [pmagnus.btcdata.web.controllers.transactions :as tx] [pmagnus.btcdata.web.middleware.exception :as exception] [pmagnus.btcdata.web.middleware.formats :as formats] [reitit.coercion.malli :as malli] @@ -161,7 +162,17 @@ ["/strike/ws" {:get (fn [req] (ws-strike-handler opts req)) :no-doc true - :middleware []}]]) + :middleware []}] + ["/wallets" + {:get (fn [req] (tx/list-wallets opts req)) + :post (fn [req] (tx/create-wallet! opts req))}] + ["/wallets/balances" + {:get (fn [req] (tx/get-wallet-balances opts req))}] + ["/wallets/:id/events" + {:get (fn [req] (tx/get-wallet-events opts req))}] + ["/events" + {:get (fn [req] (tx/list-events opts req)) + :post (fn [req] (tx/create-event! opts req))}]]) (defn route-data [opts] (merge