1
use finance::{commodity::Commodity, tag::Tag};
2
use num_rational::Rational64;
3
use sqlx::types::Uuid;
4
use std::{collections::HashMap, fmt::Debug};
5
use supp_macro::command;
6

            
7
use super::{CmdError, CmdResult};
8
use crate::{command::FinanceEntity, config::ConfigError, user::User};
9

            
10
command! {
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
2
                .map(|row| {
36
2
                    (
37
2
                        row.tag_name.clone(),
38
2
                        FinanceEntity::Tag(Tag {
39
2
                            id: row.id,
40
2
                            tag_name: row.tag_name,
41
2
                            tag_value: row.tag_value,
42
2
                            description: row.description,
43
2
                        }),
44
2
                    )
45
2
                })
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
123
}
55

            
56
command! {
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
10649
}
76

            
77
command! {
78
    ListCommodities {
79
        #[required]
80
        user_id: Uuid,
81
    } => {
82
        let user = User { id: user_id };
83
101
        let mut conn = user.get_connection().await.map_err(|err| {
84
101
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
85
101
            ConfigError::DB
86
101
        })?;
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
585
            .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
1170
                    .map(|row| {
105
1170
                        (
106
1170
                            row.tag_name.clone(),
107
1170
                            FinanceEntity::Tag(Tag {
108
1170
                                id: row.id,
109
1170
                                tag_name: row.tag_name,
110
1170
                                tag_value: row.tag_value,
111
1170
                                description: row.description,
112
1170
                            }),
113
1170
                        )
114
1170
                    })
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
3887
}
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.
131
93
fn checked_ratio_mul(a: Rational64, b: Rational64) -> Option<Rational64> {
132
93
    fn gcd_i128(mut x: i128, mut y: i128) -> i128 {
133
186
        while y != 0 {
134
93
            (x, y) = (y, x % y);
135
93
        }
136
93
        x.abs()
137
93
    }
138
93
    let num = i128::from(*a.numer()) * i128::from(*b.numer());
139
93
    let den = i128::from(*a.denom()) * i128::from(*b.denom());
140
93
    let g = gcd_i128(num, den).max(1);
141
93
    let num = i64::try_from(num / g).ok()?;
142
62
    let den = i64::try_from(den / g).ok()?;
143
62
    Some(Rational64::new(num, den))
144
93
}
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.
150
command! {
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
25
        let mut conn = user.get_connection().await.map_err(|err| {
165
25
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
166
25
            ConfigError::DB
167
25
        })?;
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
25
            let converted = checked_ratio_mul(amount, price).ok_or_else(|| {
194
25
                CmdError::Args("convert-commodity: conversion overflow".to_string())
195
25
            })?;
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
1435
}
223

            
224
#[cfg(test)]
225
mod 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
3
    async fn setup() {
237
3
        CONTEXT
238
3
            .get_or_init(|| async {
239
                #[cfg(feature = "testlog")]
240
1
                let _ = env_logger::builder()
241
1
                    .is_test(true)
242
1
                    .filter_level(log::LevelFilter::Trace)
243
1
                    .try_init();
244
2
            })
245
3
            .await;
246
3
        USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
247
3
            .await;
248
3
    }
249

            
250
    #[test]
251
1
    fn checked_ratio_mul_normal_case_reduces() {
252
1
        let got = checked_ratio_mul(Rational64::new(100, 1), Rational64::new(9, 10));
253
1
        assert_eq!(got, Some(Rational64::new(90, 1)));
254
1
    }
255

            
256
    #[test]
257
1
    fn checked_ratio_mul_overflow_returns_none() {
258
1
        let huge = Rational64::new(i64::MAX, 1);
259
1
        assert_eq!(checked_ratio_mul(huge, Rational64::new(i64::MAX, 1)), None);
260
1
    }
261

            
262
    #[test]
263
1
    fn checked_ratio_mul_zero_numerator_is_zero() {
264
1
        let got = checked_ratio_mul(Rational64::new(0, 1), Rational64::new(7, 3));
265
1
        assert_eq!(got, Some(Rational64::new(0, 1)));
266
1
    }
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
}