Skip to main content

server/command/
commodity.rs

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        // For each commodity, get its tags
29        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        // Get all commodities
89        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        // For each commodity, get its tags
97        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
126/// Multiplies two rationals through i128 widening, reduces the i128 product
127/// by its gcd, and verifies both parts fit back in i64. Returns `None` on
128/// overflow so the caller surfaces a clean error instead of panicking
129/// (debug) or wrapping (release). Both operands carry a positive denominator
130/// (callers validate), so the product denominator stays positive.
131fn 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
146// Converts a source-commodity amount into a target-commodity amount
147// using the most recent Price row that links the two. Looks up the
148// direct `source -> target` row first; on miss, tries the inverse
149// `target -> source` row and inverts the ratio.
150command! {
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    /// Context for keeping environment intact
233    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        // Create a test commodity with tags
295        CreateCommodity::new()
296            .symbol("TST".to_string())
297            .name("Test Commodity".to_string())
298            .user_id(user.id)
299            .run()
300            .await?;
301
302        // List commodities
303        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                // Check tags
311                assert_eq!(tags.len(), 2); // symbol and name tags
312                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        // First create a commodity
337        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        // Get the commodity ID
345        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        // Test GetCommodity command
352        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                // Check tags
365                assert_eq!(tags.len(), 2); // symbol and name tags
366                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        // Test with non-existent commodity ID
383        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}