1use 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
126fn checked_ratio_mul(a: Rational64, b: Rational64) -> Option<Rational64> {
132 fn gcd_i128(mut x: i128, mut y: i128) -> i128 {
133 while y != 0 {
134 (x, y) = (y, x % y);
135 }
136 x.abs()
137 }
138 let num = i128::from(*a.numer()) * i128::from(*b.numer());
139 let den = i128::from(*a.denom()) * i128::from(*b.denom());
140 let g = gcd_i128(num, den).max(1);
141 let num = i64::try_from(num / g).ok()?;
142 let den = i64::try_from(den / g).ok()?;
143 Some(Rational64::new(num, den))
144}
145
146command! {
151 ConvertCommodity {
152 #[required]
153 user_id: Uuid,
154 #[required]
155 amount_num: i64,
156 #[required]
157 amount_denom: i64,
158 #[required]
159 source_commodity_id: Uuid,
160 #[required]
161 target_commodity_id: Uuid,
162 } => {
163 let user = User { id: user_id };
164 let mut conn = user.get_connection().await.map_err(|err| {
165 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
166 ConfigError::DB
167 })?;
168
169 if amount_denom == 0 {
170 return Err(CmdError::Args(
171 "convert-commodity: amount has zero denominator".to_string(),
172 ));
173 }
174 let amount = Rational64::new(amount_num, amount_denom);
175
176 if source_commodity_id == target_commodity_id {
177 return Ok(Some(CmdResult::Rational(amount)));
178 }
179
180 if let Some(row) = sqlx::query_file!(
181 "sql/select/prices/latest_between.sql",
182 &source_commodity_id,
183 &target_commodity_id,
184 )
185 .fetch_optional(&mut *conn)
186 .await? {
187 if row.value_denom == 0 {
188 return Err(CmdError::Args(
189 "convert-commodity: price has zero denominator".to_string(),
190 ));
191 }
192 let price = Rational64::new(row.value_num, row.value_denom);
193 let converted = checked_ratio_mul(amount, price).ok_or_else(|| {
194 CmdError::Args("convert-commodity: conversion overflow".to_string())
195 })?;
196 return Ok(Some(CmdResult::Rational(converted)));
197 }
198
199 if let Some(row) = sqlx::query_file!(
200 "sql/select/prices/latest_between.sql",
201 &target_commodity_id,
202 &source_commodity_id,
203 )
204 .fetch_optional(&mut *conn)
205 .await? {
206 if row.value_num == 0 {
207 return Err(CmdError::Args(
208 "convert-commodity: inverse price has zero numerator".to_string(),
209 ));
210 }
211 let inverse = Rational64::new(row.value_denom, row.value_num);
212 let converted = checked_ratio_mul(amount, inverse).ok_or_else(|| {
213 CmdError::Args("convert-commodity: conversion overflow".to_string())
214 })?;
215 return Ok(Some(CmdResult::Rational(converted)));
216 }
217
218 Err(CmdError::Args(format!(
219 "convert-commodity: no Price row between {source_commodity_id} and {target_commodity_id}"
220 )))
221 }
222}
223
224#[cfg(test)]
225mod command_tests {
226 use super::*;
227 use crate::db::DB_POOL;
228 use sqlx::PgPool;
229 use supp_macro::local_db_sqlx_test;
230 use tokio::sync::OnceCell;
231
232 static CONTEXT: OnceCell<()> = OnceCell::const_new();
234 static USER: OnceCell<User> = OnceCell::const_new();
235
236 async fn setup() {
237 CONTEXT
238 .get_or_init(|| async {
239 #[cfg(feature = "testlog")]
240 let _ = env_logger::builder()
241 .is_test(true)
242 .filter_level(log::LevelFilter::Trace)
243 .try_init();
244 })
245 .await;
246 USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
247 .await;
248 }
249
250 #[test]
251 fn checked_ratio_mul_normal_case_reduces() {
252 let got = checked_ratio_mul(Rational64::new(100, 1), Rational64::new(9, 10));
253 assert_eq!(got, Some(Rational64::new(90, 1)));
254 }
255
256 #[test]
257 fn checked_ratio_mul_overflow_returns_none() {
258 let huge = Rational64::new(i64::MAX, 1);
259 assert_eq!(checked_ratio_mul(huge, Rational64::new(i64::MAX, 1)), None);
260 }
261
262 #[test]
263 fn checked_ratio_mul_zero_numerator_is_zero() {
264 let got = checked_ratio_mul(Rational64::new(0, 1), Rational64::new(7, 3));
265 assert_eq!(got, Some(Rational64::new(0, 1)));
266 }
267
268 #[local_db_sqlx_test]
269 async fn test_list_commodities_empty(pool: PgPool) -> anyhow::Result<()> {
270 let user = USER.get().unwrap();
271 user.commit()
272 .await
273 .expect("Failed to commit user to database");
274
275 if let Some(CmdResult::TaggedEntities { entities, .. }) =
276 ListCommodities::new().user_id(user.id).run().await?
277 {
278 assert!(
279 entities.is_empty(),
280 "Expected no commodities in empty database"
281 );
282 } else {
283 panic!("Expected TaggedEntities result");
284 }
285 }
286
287 #[local_db_sqlx_test]
288 async fn test_list_commodities_with_data(pool: PgPool) -> anyhow::Result<()> {
289 let user = USER.get().unwrap();
290 user.commit()
291 .await
292 .expect("Failed to commit user to database");
293
294 CreateCommodity::new()
296 .symbol("TST".to_string())
297 .name("Test Commodity".to_string())
298 .user_id(user.id)
299 .run()
300 .await?;
301
302 if let Some(CmdResult::TaggedEntities { entities, .. }) =
304 ListCommodities::new().user_id(user.id).run().await?
305 {
306 assert_eq!(entities.len(), 1, "Expected one commodity");
307
308 let (entity, tags) = &entities[0];
309 if let FinanceEntity::Commodity(_c) = entity {
310 assert_eq!(tags.len(), 2); for tag in tags.values() {
313 if let FinanceEntity::Tag(t) = tag {
314 match t.tag_name.as_str() {
315 "symbol" => assert_eq!(t.tag_value, "TST"),
316 "name" => assert_eq!(t.tag_value, "Test Commodity"),
317 _ => panic!("Unexpected tag: {}", t.tag_name),
318 }
319 }
320 }
321 } else {
322 panic!("Expected Commodity entity");
323 }
324 } else {
325 panic!("Expected TaggedEntities result");
326 }
327 }
328
329 #[local_db_sqlx_test]
330 async fn test_get_commodity(pool: PgPool) -> anyhow::Result<()> {
331 let user = USER.get().unwrap();
332 user.commit()
333 .await
334 .expect("Failed to commit user to database");
335
336 let commodity_result = CreateCommodity::new()
338 .symbol("TST".to_string())
339 .name("Test Commodity".to_string())
340 .user_id(user.id)
341 .run()
342 .await?;
343
344 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
346 uuid::Uuid::parse_str(&id)?
347 } else {
348 panic!("Expected commodity ID string result");
349 };
350
351 if let Some(CmdResult::TaggedEntities { entities, .. }) = GetCommodity::new()
353 .user_id(user.id)
354 .commodity_id(commodity_id)
355 .run()
356 .await?
357 {
358 assert_eq!(entities.len(), 1, "Expected one commodity");
359
360 let (entity, tags) = &entities[0];
361 if let FinanceEntity::Commodity(c) = entity {
362 assert_eq!(c.id, commodity_id);
363
364 assert_eq!(tags.len(), 2); for tag in tags.values() {
367 if let FinanceEntity::Tag(t) = tag {
368 match t.tag_name.as_str() {
369 "symbol" => assert_eq!(t.tag_value, "TST"),
370 "name" => assert_eq!(t.tag_value, "Test Commodity"),
371 _ => panic!("Unexpected tag: {}", t.tag_name),
372 }
373 }
374 }
375 } else {
376 panic!("Expected Commodity entity");
377 }
378 } else {
379 panic!("Expected TaggedEntities result");
380 }
381
382 let result = GetCommodity::new()
384 .user_id(user.id)
385 .commodity_id(Uuid::new_v4())
386 .run()
387 .await;
388 assert!(result.is_err(), "Expected error for non-existent commodity");
389 }
390}