Add deposits read table (CQRS projection from bank_to_exchange events)

Project each bank_to_exchange event into a denormalized deposits table
with pre-computed EUR, DKK, and USD amounts using Frankfurter currency
rates. Replaces the aggregate get-deposit-totals query.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 12:52:32 +01:00
co-authored by Claude Opus 4.6
parent 0446d928e6
commit 174ea24786
5 changed files with 89 additions and 19 deletions
@@ -0,0 +1 @@
DROP TABLE IF EXISTS deposits;
@@ -0,0 +1,12 @@
CREATE TABLE deposits (
id BIGSERIAL PRIMARY KEY,
event_id BIGINT NOT NULL REFERENCES events(id) UNIQUE,
occurred_at TIMESTAMPTZ NOT NULL,
exchange TEXT NOT NULL,
fiat_amount NUMERIC(18,2) NOT NULL,
fiat_currency TEXT NOT NULL,
amount_eur NUMERIC(18,2),
amount_dkk NUMERIC(18,2),
amount_usd NUMERIC(18,2),
note TEXT
);
+14 -11
View File
@@ -82,10 +82,11 @@ SELECT id, name, created_at FROM wallets WHERE id = :id
-- Events -------------------------------------------------------------------- -- Events --------------------------------------------------------------------
-- :name insert-event! :! :n -- :name insert-event! :? :1
-- :doc Insert a new event -- :doc Insert a new event, returning its id
INSERT INTO events (event_type, occurred_at, sats, fee_sats, fiat_amount, fiat_currency, from_wallet_id, to_wallet_id, note) 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) VALUES (:event-type, :occurred-at, :sats, :fee-sats, :fiat-amount, :fiat-currency, :from-wallet-id, :to-wallet-id, :note)
RETURNING id
-- :name get-all-events :? :* -- :name get-all-events :? :*
-- :doc Get all events with wallet names -- :doc Get all events with wallet names
@@ -125,15 +126,17 @@ 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 WHERE e.from_wallet_id = :wallet-id OR e.to_wallet_id = :wallet-id
ORDER BY e.occurred_at DESC ORDER BY e.occurred_at DESC
-- :name get-deposit-totals :? :* -- :name insert-deposit! :! :n
-- :doc Total fiat deposited per exchange per currency from bank_to_exchange events -- :doc Insert a projected deposit row
SELECT tw.name AS exchange, e.fiat_currency AS currency, INSERT INTO deposits (event_id, occurred_at, exchange, fiat_amount, fiat_currency, amount_eur, amount_dkk, amount_usd, note)
SUM(e.fiat_amount) AS total VALUES (:event-id, :occurred-at, :exchange, :fiat-amount, :fiat-currency, :amount-eur, :amount-dkk, :amount-usd, :note)
FROM events e
JOIN wallets tw ON tw.id = e.to_wallet_id -- :name get-all-deposits :? :*
WHERE e.event_type = 'bank_to_exchange' -- :doc Get all projected deposits ordered by date descending
GROUP BY tw.name, e.fiat_currency SELECT id, event_id, occurred_at, exchange, fiat_amount, fiat_currency,
ORDER BY tw.name, e.fiat_currency amount_eur, amount_dkk, amount_usd, note
FROM deposits
ORDER BY occurred_at DESC
-- :name get-wallet-balances :? :* -- :name get-wallet-balances :? :*
-- :doc Compute sats balance per wallet from events -- :doc Compute sats balance per wallet from events
@@ -1,9 +1,11 @@
(ns pmagnus.btcdata.web.controllers.transactions (ns pmagnus.btcdata.web.controllers.transactions
(:require (:require
[clojure.string :as str] [clojure.string :as str]
[clojure.tools.logging :as log]
[pmagnus.btcdata.frankfurter.rates :as rates]
[ring.util.http-response :as response]) [ring.util.http-response :as response])
(:import (:import
[java.time Instant] [java.time Instant LocalDate ZoneOffset]
[java.util UUID])) [java.util UUID]))
(defn list-wallets [{:keys [query-fn]} _req] (defn list-wallets [{:keys [query-fn]} _req]
@@ -19,7 +21,53 @@
(defn get-wallet-balances [{:keys [query-fn]} _req] (defn get-wallet-balances [{:keys [query-fn]} _req]
(response/ok (query-fn :get-wallet-balances {}))) (response/ok (query-fn :get-wallet-balances {})))
(defn create-event! [{:keys [query-fn]} req] (defn- convert-amount
"Convert fiat-amount in fiat-currency to EUR, DKK, USD using rates map.
rates has keys :eur (EUR/USD), :dkk (DKK/USD), :eur_dkk (DKK/EUR)."
[fiat-amount fiat-currency rates]
(let [amt (bigdec fiat-amount)
eur (:eur rates)
dkk (:dkk rates)
eur-dkk (:eur_dkk rates)]
(case fiat-currency
"EUR" {:amount-eur amt
:amount-dkk (.setScale (* amt eur-dkk) 2 java.math.RoundingMode/HALF_UP)
:amount-usd (.setScale (.divide amt eur 6 java.math.RoundingMode/HALF_UP) 2 java.math.RoundingMode/HALF_UP)}
"USD" {:amount-eur (.setScale (* amt eur) 2 java.math.RoundingMode/HALF_UP)
:amount-dkk (.setScale (* amt dkk) 2 java.math.RoundingMode/HALF_UP)
:amount-usd amt}
"DKK" {:amount-eur (.setScale (.divide amt eur-dkk 6 java.math.RoundingMode/HALF_UP) 2 java.math.RoundingMode/HALF_UP)
:amount-dkk amt
:amount-usd (.setScale (.divide amt dkk 6 java.math.RoundingMode/HALF_UP) 2 java.math.RoundingMode/HALF_UP)}
;; Unknown currency — store nil conversions
{:amount-eur nil :amount-dkk nil :amount-usd nil})))
(defn- project-deposit!
"Project a bank_to_exchange event into the deposits read table."
[{:keys [query-fn frankfurter]} event-id occurred-at to-wallet-id fiat-amount fiat-currency note]
(try
(let [wallet (query-fn :get-wallet-by-id {:id to-wallet-id})
exchange (or (:name wallet) "Unknown")
date (.toLocalDate (.atOffset occurred-at ZoneOffset/UTC))
rates (or (query-fn :get-currency-rates-by-date {:rate-date date})
(rates/fetch-and-persist-for-date!
(:client frankfurter) (:url frankfurter)
query-fn date))
amounts (if rates
(convert-amount fiat-amount fiat-currency rates)
{:amount-eur nil :amount-dkk nil :amount-usd nil})]
(query-fn :insert-deposit!
(merge {:event-id event-id
:occurred-at occurred-at
:exchange exchange
:fiat-amount fiat-amount
:fiat-currency fiat-currency
:note note}
amounts)))
(catch Exception e
(log/error e "Failed to project deposit for event" event-id))))
(defn create-event! [{:keys [query-fn] :as opts} req]
(let [params (:body-params req) (let [params (:body-params req)
event-type (:event_type params)] event-type (:event_type params)]
(if-not (#{"bank_to_exchange" "exchange_to_wallet" "wallet_to_wallet"} event-type) (if-not (#{"bank_to_exchange" "exchange_to_wallet" "wallet_to_wallet"} event-type)
@@ -35,12 +83,18 @@
:fiat-currency (:fiat_currency params) :fiat-currency (:fiat_currency params)
:from-wallet-id (some-> (:from_wallet_id params) UUID/fromString) :from-wallet-id (some-> (:from_wallet_id params) UUID/fromString)
:to-wallet-id (some-> (:to_wallet_id params) UUID/fromString) :to-wallet-id (some-> (:to_wallet_id params) UUID/fromString)
:note (:note params)}] :note (:note params)}
(query-fn :insert-event! row) result (query-fn :insert-event! row)]
(when (= event-type "bank_to_exchange")
(project-deposit! opts (:id result) occurred-at
(some-> (:to_wallet_id params) UUID/fromString)
(:fiat_amount params)
(:fiat_currency params)
(:note params)))
(response/created "/api/events" {:status "ok"}))))) (response/created "/api/events" {:status "ok"})))))
(defn get-deposit-totals [{:keys [query-fn]} _req] (defn list-deposits [{:keys [query-fn]} _req]
(response/ok (query-fn :get-deposit-totals {}))) (response/ok (query-fn :get-all-deposits {})))
(defn list-events [{:keys [query-fn]} req] (defn list-events [{:keys [query-fn]} req]
(let [event-type (get-in req [:query-params "type"])] (let [event-type (get-in req [:query-params "type"])]
+2 -2
View File
@@ -173,8 +173,8 @@
["/events" ["/events"
{:get (fn [req] (tx/list-events opts req)) {:get (fn [req] (tx/list-events opts req))
:post (fn [req] (tx/create-event! opts req))}] :post (fn [req] (tx/create-event! opts req))}]
["/deposits/totals" ["/deposits"
{:get (fn [req] (tx/get-deposit-totals opts req))}]]) {:get (fn [req] (tx/list-deposits opts req))}]])
(defn route-data [opts] (defn route-data [opts]
(merge (merge