server/command/
commodity.rs1use finance::{commodity::Commodity, tag::Tag};
2use num_rational::Rational64;
3use sqlx::types::Uuid;
4use std::{collections::HashMap, fmt::Debug};
5use supp_macro::command;
6
7use super::{CmdError, CmdResult};
8use crate::{command::FinanceEntity, config::ConfigError, user::User};
9
10command! {
11 GetCommodity {
12 #[required]
13 user_id: Uuid,
14 #[required]
15 commodity_id: Uuid,
16 } => {
17 let user = User { id: user_id };
18
19 let mut conn = user.get_connection().await.map_err(|err| {
20 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
21 ConfigError::DB
22 })?;
23
24 let comm = sqlx::query_file_as!(Commodity, "sql/select/commodities/by_id.sql", &commodity_id)
25 .fetch_one(&mut *conn)
26 .await?;
27
28 let mut tagged_entities = Vec::new();
30 let tags: HashMap<String, FinanceEntity> =
31 sqlx::query_file!("sql/select/tags/by_commodity.sql", &commodity_id)
32 .fetch_all(&mut *conn)
33 .await?
34 .into_iter()
35 .map(|row| {
36 (
37 row.tag_name.clone(),
38 FinanceEntity::Tag(Tag {
39 id: row.id,
40 tag_name: row.tag_name,
41 tag_value: row.tag_value,
42 description: row.description,
43 }),
44 )
45 })
46 .collect();
47
48 tagged_entities.push((FinanceEntity::Commodity(comm), tags));
49 Ok(Some(CmdResult::TaggedEntities {
50 entities: tagged_entities,
51 pagination: None,
52 }))
53 }
54}
55
56command! {
57 CreateCommodity {
58 #[required]
59 symbol: String,
60 #[required]
61 name: String,
62 #[required]
63 user_id: Uuid,
64 } => {
65 let user = User { id: user_id };
66
67 Ok(Some(
68 user.create_commodity(symbol, name)
69 .await?
70 .id
71 .to_string()
72 .into(),
73 ))
74 }
75}
76
77command! {
78 ListCommodities {
79 #[required]
80 user_id: Uuid,
81 } => {
82 let user = User { id: user_id };
83 let mut conn = user.get_connection().await.map_err(|err| {
84 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
85 ConfigError::DB
86 })?;
87
88 let commodities: Vec<Commodity> = sqlx::query_file!("sql/select/commodities/all.sql")
90 .fetch_all(&mut *conn)
91 .await?
92 .into_iter()
93 .map(|row| Commodity { id: row.id })
94 .collect();
95
96 let mut tagged_entities = Vec::new();
98 for commodity in commodities {
99 let tags: HashMap<String, FinanceEntity> =
100 sqlx::query_file!("sql/select/tags/by_commodity.sql", &commodity.id)
101 .fetch_all(&mut *conn)
102 .await?
103 .into_iter()
104 .map(|row| {
105 (
106 row.tag_name.clone(),
107 FinanceEntity::Tag(Tag {
108 id: row.id,
109 tag_name: row.tag_name,
110 tag_value: row.tag_value,
111 description: row.description,
112 }),
113 )
114 })
115 .collect();
116
117 tagged_entities.push((FinanceEntity::Commodity(commodity), tags));
118 }
119 Ok(Some(CmdResult::TaggedEntities {
120 entities: tagged_entities,
121 pagination: None,
122 }))
123 }
124}
125
126command! {
131 ConvertCommodity {
132 #[required]
133 user_id: Uuid,
134 #[required]
135 amount_num: i64,
136 #[required]
137 amount_denom: i64,
138 #[required]
139 source_commodity_id: Uuid,
140 #[required]
141 target_commodity_id: Uuid,
142 } => {
143 let user = User { id: user_id };
144 let mut conn = user.get_connection().await.map_err(|err| {
145 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
146 ConfigError::DB
147 })?;
148
149 if amount_denom == 0 {
150 return Err(CmdError::Args(
151 "convert-commodity: amount has zero denominator".to_string(),
152 ));
153 }
154 let amount = Rational64::new(amount_num, amount_denom);
155
156 if source_commodity_id == target_commodity_id {
157 return Ok(Some(CmdResult::Rational(amount)));
158 }
159
160 if let Some(row) = sqlx::query_file!(
161 "sql/select/prices/latest_between.sql",
162 &source_commodity_id,
163 &target_commodity_id,
164 )
165 .fetch_optional(&mut *conn)
166 .await? {
167 let price = Rational64::new(row.value_num, row.value_denom);
168 return Ok(Some(CmdResult::Rational(amount * price)));
169 }
170
171 if let Some(row) = sqlx::query_file!(
172 "sql/select/prices/latest_between.sql",
173 &target_commodity_id,
174 &source_commodity_id,
175 )
176 .fetch_optional(&mut *conn)
177 .await? {
178 if row.value_num == 0 {
179 return Err(CmdError::Args(
180 "convert-commodity: inverse price has zero numerator".to_string(),
181 ));
182 }
183 let inverse = Rational64::new(row.value_denom, row.value_num);
184 return Ok(Some(CmdResult::Rational(amount * inverse)));
185 }
186
187 Err(CmdError::Args(format!(
188 "convert-commodity: no Price row between {source_commodity_id} and {target_commodity_id}"
189 )))
190 }
191}
192
193#[cfg(test)]
194mod command_tests {
195 use super::*;
196 use crate::db::DB_POOL;
197 use sqlx::PgPool;
198 use supp_macro::local_db_sqlx_test;
199 use tokio::sync::OnceCell;
200
201 static CONTEXT: OnceCell<()> = OnceCell::const_new();
203 static USER: OnceCell<User> = OnceCell::const_new();
204
205 async fn setup() {
206 CONTEXT
207 .get_or_init(|| async {
208 #[cfg(feature = "testlog")]
209 let _ = env_logger::builder()
210 .is_test(true)
211 .filter_level(log::LevelFilter::Trace)
212 .try_init();
213 })
214 .await;
215 USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
216 .await;
217 }
218
219 #[local_db_sqlx_test]
220 async fn test_list_commodities_empty(pool: PgPool) -> anyhow::Result<()> {
221 let user = USER.get().unwrap();
222 user.commit()
223 .await
224 .expect("Failed to commit user to database");
225
226 if let Some(CmdResult::TaggedEntities { entities, .. }) =
227 ListCommodities::new().user_id(user.id).run().await?
228 {
229 assert!(
230 entities.is_empty(),
231 "Expected no commodities in empty database"
232 );
233 } else {
234 panic!("Expected TaggedEntities result");
235 }
236 }
237
238 #[local_db_sqlx_test]
239 async fn test_list_commodities_with_data(pool: PgPool) -> anyhow::Result<()> {
240 let user = USER.get().unwrap();
241 user.commit()
242 .await
243 .expect("Failed to commit user to database");
244
245 CreateCommodity::new()
247 .symbol("TST".to_string())
248 .name("Test Commodity".to_string())
249 .user_id(user.id)
250 .run()
251 .await?;
252
253 if let Some(CmdResult::TaggedEntities { entities, .. }) =
255 ListCommodities::new().user_id(user.id).run().await?
256 {
257 assert_eq!(entities.len(), 1, "Expected one commodity");
258
259 let (entity, tags) = &entities[0];
260 if let FinanceEntity::Commodity(_c) = entity {
261 assert_eq!(tags.len(), 2); for tag in tags.values() {
264 if let FinanceEntity::Tag(t) = tag {
265 match t.tag_name.as_str() {
266 "symbol" => assert_eq!(t.tag_value, "TST"),
267 "name" => assert_eq!(t.tag_value, "Test Commodity"),
268 _ => panic!("Unexpected tag: {}", t.tag_name),
269 }
270 }
271 }
272 } else {
273 panic!("Expected Commodity entity");
274 }
275 } else {
276 panic!("Expected TaggedEntities result");
277 }
278 }
279
280 #[local_db_sqlx_test]
281 async fn test_get_commodity(pool: PgPool) -> anyhow::Result<()> {
282 let user = USER.get().unwrap();
283 user.commit()
284 .await
285 .expect("Failed to commit user to database");
286
287 let commodity_result = CreateCommodity::new()
289 .symbol("TST".to_string())
290 .name("Test Commodity".to_string())
291 .user_id(user.id)
292 .run()
293 .await?;
294
295 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
297 uuid::Uuid::parse_str(&id)?
298 } else {
299 panic!("Expected commodity ID string result");
300 };
301
302 if let Some(CmdResult::TaggedEntities { entities, .. }) = GetCommodity::new()
304 .user_id(user.id)
305 .commodity_id(commodity_id)
306 .run()
307 .await?
308 {
309 assert_eq!(entities.len(), 1, "Expected one commodity");
310
311 let (entity, tags) = &entities[0];
312 if let FinanceEntity::Commodity(c) = entity {
313 assert_eq!(c.id, commodity_id);
314
315 assert_eq!(tags.len(), 2); for tag in tags.values() {
318 if let FinanceEntity::Tag(t) = tag {
319 match t.tag_name.as_str() {
320 "symbol" => assert_eq!(t.tag_value, "TST"),
321 "name" => assert_eq!(t.tag_value, "Test Commodity"),
322 _ => panic!("Unexpected tag: {}", t.tag_name),
323 }
324 }
325 }
326 } else {
327 panic!("Expected Commodity entity");
328 }
329 } else {
330 panic!("Expected TaggedEntities result");
331 }
332
333 let result = GetCommodity::new()
335 .user_id(user.id)
336 .commodity_id(Uuid::new_v4())
337 .run()
338 .await;
339 assert!(result.is_err(), "Expected error for non-existent commodity");
340 }
341}