Add event-sourced wallet and transaction tracking system
Wallets table with 7 seeded wallets, events table for three transaction types (bank_to_exchange, exchange_to_wallet, wallet_to_wallet). Balances computed from event credits/debits in sats. REST endpoints for CRUD. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE wallets;
|
||||||
@@ -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');
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE events;
|
||||||
@@ -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);
|
||||||
@@ -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
|
-- :name get-currency-rates-by-date :? :1
|
||||||
-- :doc Get currency rates for a specific date
|
-- :doc Get currency rates for a specific date
|
||||||
SELECT rate_date, eur, dkk, eur_dkk, updated_at FROM currencies WHERE rate_date = :rate-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
|
||||||
|
|||||||
@@ -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}))))
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
(if (= :options (:request-method request))
|
(if (= :options (:request-method request))
|
||||||
{:status 204
|
{:status 204
|
||||||
:headers {"Access-Control-Allow-Origin" origin
|
: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-Allow-Headers" "Content-Type"
|
||||||
"Access-Control-Max-Age" "86400"}}
|
"Access-Control-Max-Age" "86400"}}
|
||||||
(when-let [resp (handler request)]
|
(when-let [resp (handler request)]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
[integrant.core :as ig]
|
[integrant.core :as ig]
|
||||||
[pmagnus.btcdata.frankfurter.rates :as rates]
|
[pmagnus.btcdata.frankfurter.rates :as rates]
|
||||||
[pmagnus.btcdata.web.controllers.health :as health]
|
[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.exception :as exception]
|
||||||
[pmagnus.btcdata.web.middleware.formats :as formats]
|
[pmagnus.btcdata.web.middleware.formats :as formats]
|
||||||
[reitit.coercion.malli :as malli]
|
[reitit.coercion.malli :as malli]
|
||||||
@@ -161,7 +162,17 @@
|
|||||||
["/strike/ws"
|
["/strike/ws"
|
||||||
{:get (fn [req] (ws-strike-handler opts req))
|
{:get (fn [req] (ws-strike-handler opts req))
|
||||||
:no-doc true
|
: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]
|
(defn route-data [opts]
|
||||||
(merge
|
(merge
|
||||||
|
|||||||
Reference in New Issue
Block a user