Skip to main content

web/pages/transaction/
util.rs

1//web/src/pages/transaction/util.rs - Shared utility functions for transaction operations
2
3use axum::{Json, http::StatusCode};
4use chrono::{Local, NaiveDateTime};
5use finance::price::Price;
6use num_rational::Rational64;
7use serde::Deserialize;
8use server::command::{CmdResult, FinanceEntity, commodity::GetCommodity};
9use sqlx::types::Uuid;
10use std::collections::HashMap;
11
12/// Common `SplitData` structure used by both create and edit
13#[derive(Deserialize, Debug)]
14pub struct SplitData {
15    pub split_id: Option<String>, // Only used by edit
16    pub amount: String,
17    pub amount_converted: String,
18    pub from_account: String,
19    pub to_account: String,
20    pub from_commodity: String,
21    pub to_commodity: String,
22    pub from_tags: Option<Vec<TagData>>,
23    pub to_tags: Option<Vec<TagData>>,
24}
25
26#[derive(Deserialize, Debug)]
27pub struct TagData {
28    pub name: String,
29    pub value: String,
30    pub description: Option<String>,
31}
32
33/// Result of processing a single split
34pub struct ProcessedSplit {
35    pub from_split: finance::split::Split,
36    pub to_split: finance::split::Split,
37    pub price: Option<Price>,
38    pub from_split_tags: Option<Vec<TagData>>,
39    pub to_split_tags: Option<Vec<TagData>>,
40}
41
42/// Get account name by ID
43pub async fn get_account_name(
44    user_id: Uuid,
45    account_id: Uuid,
46) -> Result<String, Box<dyn std::error::Error>> {
47    match server::command::account::GetAccount::new()
48        .user_id(user_id)
49        .account_id(account_id)
50        .run()
51        .await?
52    {
53        Some(CmdResult::TaggedEntities { entities, .. }) => {
54            if let Some((FinanceEntity::Account(_account), tags)) = entities.first() {
55                if let Some(FinanceEntity::Tag(name_tag)) = tags.get("name") {
56                    Ok(name_tag.tag_value.clone())
57                } else {
58                    Ok("Unnamed Account".to_string())
59                }
60            } else {
61                Ok("Unknown Account".to_string())
62            }
63        }
64        _ => Ok("Unknown Account".to_string()),
65    }
66}
67
68/// Get commodity symbol (or name as fallback) by ID
69pub async fn get_commodity_name(
70    user_id: Uuid,
71    commodity_id: Uuid,
72) -> Result<String, Box<dyn std::error::Error>> {
73    match GetCommodity::new()
74        .user_id(user_id)
75        .commodity_id(commodity_id)
76        .run()
77        .await?
78    {
79        Some(CmdResult::TaggedEntities { entities, .. }) => {
80            if let Some((FinanceEntity::Commodity(_commodity), tags)) = entities.first() {
81                if let Some(FinanceEntity::Tag(symbol_tag)) = tags.get("symbol") {
82                    Ok(symbol_tag.tag_value.clone())
83                } else if let Some(FinanceEntity::Tag(name_tag)) = tags.get("name") {
84                    Ok(name_tag.tag_value.clone())
85                } else {
86                    Ok("Unknown Currency".to_string())
87                }
88            } else {
89                Ok("Unknown Currency".to_string())
90            }
91        }
92        _ => Ok("Unknown Currency".to_string()),
93    }
94}
95
96/// Parse RFC3339 date string from browser (via `toISOString()`) or return current time.
97#[must_use]
98pub fn parse_transaction_date(date_str: Option<&str>) -> NaiveDateTime {
99    date_str
100        .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
101        .map_or_else(|| Local::now().naive_utc(), |dt| dt.naive_utc())
102}
103
104/// Parse and validate UUID with custom error message
105pub fn parse_uuid(
106    uuid_str: &str,
107    field_name: &str,
108) -> Result<Uuid, (StatusCode, Json<serde_json::Value>)> {
109    Uuid::parse_str(uuid_str).map_err(|_| {
110        let error_response = serde_json::json!({
111            "status": "fail",
112            "message": format!("Invalid {}: {}", field_name, uuid_str),
113        });
114        (StatusCode::BAD_REQUEST, Json(error_response))
115    })
116}
117
118/// Validate basic amount parsing and positivity (without precision checking)
119pub fn validate_basic_amount(
120    amount_str: &str,
121) -> Result<f64, (StatusCode, Json<serde_json::Value>)> {
122    let amount_value = amount_str.parse::<f64>().map_err(|_| {
123        let error_response = serde_json::json!({
124            "status": "fail",
125            "message": format!("Invalid amount: {}", amount_str),
126        });
127        (StatusCode::BAD_REQUEST, Json(error_response))
128    })?;
129
130    if amount_value <= 0.0 {
131        let error_response = serde_json::json!({
132            "status": "fail",
133            "message": t!("Split amount must be positive"),
134        });
135        return Err((StatusCode::BAD_REQUEST, Json(error_response)));
136    }
137
138    Ok(amount_value)
139}
140
141/// Parse a decimal string directly into an exact rational (numerator, denominator).
142///
143/// `"153.81"` becomes `(15381, 100)` — no floating-point intermediary.
144pub fn parse_amount_to_rational(
145    amount_str: &str,
146) -> Result<(i64, i64), (StatusCode, Json<serde_json::Value>)> {
147    validate_basic_amount(amount_str)?;
148
149    let trimmed = amount_str.trim();
150    let (numer, denom) = if let Some(dot_pos) = trimmed.find('.') {
151        let decimals = trimmed.len() - dot_pos - 1;
152        let without_dot: String = trimmed.chars().filter(|c| *c != '.').collect();
153        let n = without_dot.parse::<i64>().map_err(|_| {
154            let error_response = serde_json::json!({
155                "status": "fail",
156                "message": format!("Cannot represent amount as rational: {}", amount_str),
157            });
158            (StatusCode::BAD_REQUEST, Json(error_response))
159        })?;
160        (n, 10_i64.pow(decimals as u32))
161    } else {
162        let n = trimmed.parse::<i64>().map_err(|_| {
163            let error_response = serde_json::json!({
164                "status": "fail",
165                "message": format!("Cannot represent amount as rational: {}", amount_str),
166            });
167            (StatusCode::BAD_REQUEST, Json(error_response))
168        })?;
169        (n, 1)
170    };
171
172    let r = Rational64::new(numer, denom);
173    Ok((*r.numer(), *r.denom()))
174}
175
176/// Process a single split data into finance entities
177pub async fn process_split_data(
178    tx_id: Uuid,
179    split_data: SplitData,
180) -> Result<ProcessedSplit, (StatusCode, Json<serde_json::Value>)> {
181    validate_basic_amount(&split_data.amount)?;
182
183    let from_account_id = parse_uuid(&split_data.from_account, "from account ID")?;
184    let to_account_id = parse_uuid(&split_data.to_account, "to account ID")?;
185    let from_commodity = parse_uuid(&split_data.from_commodity, "from commodity ID")?;
186    let to_commodity = parse_uuid(&split_data.to_commodity, "to commodity ID")?;
187
188    // Only validate amount_converted if currency conversion is needed
189    let conversion = from_commodity != to_commodity;
190    if conversion {
191        validate_basic_amount(&split_data.amount_converted)?;
192    }
193
194    let (from_num, from_denom) = parse_amount_to_rational(&split_data.amount)?;
195
196    let from_split_id = Uuid::new_v4();
197    let to_split_id = Uuid::new_v4();
198
199    let (to_num, to_denom, price) = if conversion {
200        let (to_num, to_denom) = parse_amount_to_rational(&split_data.amount_converted)?;
201
202        let price = build_conversion_price(
203            from_split_id,
204            to_split_id,
205            from_commodity,
206            to_commodity,
207            from_num,
208            from_denom,
209            to_num,
210            to_denom,
211        );
212
213        (to_num, to_denom, Some(price))
214    } else {
215        (from_num, from_denom, None)
216    };
217
218    // Create split entities
219    let from_split = finance::split::Split {
220        id: from_split_id,
221        tx_id,
222        account_id: from_account_id,
223        commodity_id: from_commodity,
224        value_num: -from_num,
225        value_denom: from_denom,
226        reconcile_state: None,
227        reconcile_date: None,
228        lot_id: None,
229    };
230
231    let to_split = finance::split::Split {
232        id: to_split_id,
233        tx_id,
234        account_id: to_account_id,
235        commodity_id: to_commodity,
236        value_num: to_num,
237        value_denom: to_denom,
238        reconcile_state: None,
239        reconcile_date: None,
240        lot_id: None,
241    };
242
243    Ok(ProcessedSplit {
244        from_split,
245        to_split,
246        price,
247        from_split_tags: split_data.from_tags,
248        to_split_tags: split_data.to_tags,
249    })
250}
251
252/// Create transaction tags from note
253#[must_use]
254pub fn create_transaction_tags(note: Option<&str>) -> HashMap<String, FinanceEntity> {
255    let mut tags = HashMap::new();
256    if let Some(note_str) = note
257        && !note_str.trim().is_empty()
258    {
259        let tag = finance::tag::Tag {
260            id: Uuid::new_v4(),
261            tag_name: "note".to_string(),
262            tag_value: note_str.to_string(),
263            description: None,
264        };
265        tags.insert("note".to_string(), FinanceEntity::Tag(tag));
266    }
267    tags
268}
269
270/// Build a Price for a multi-currency split from rational components.
271///
272/// `from` is the spent amount (will be negated in the split),
273/// `to` is the received amount in a different commodity.
274/// Returns a Price whose rate converts `to` back to `from` units.
275#[must_use]
276pub fn build_conversion_price(
277    from_split_id: Uuid,
278    to_split_id: Uuid,
279    from_commodity: Uuid,
280    to_commodity: Uuid,
281    from_num: i64,
282    from_denom: i64,
283    to_num: i64,
284    to_denom: i64,
285) -> Price {
286    Price {
287        id: Uuid::new_v4(),
288        date: chrono::Utc::now(),
289        commodity_id: to_commodity,
290        currency_id: from_commodity,
291        commodity_split: Some(to_split_id),
292        currency_split: Some(from_split_id),
293        value_num: from_num * to_denom,
294        value_denom: from_denom * to_num,
295    }
296}
297
298/// Validate that splits are not empty
299pub fn validate_splits_not_empty<T>(
300    splits: &[T],
301) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
302    if splits.is_empty() {
303        let error_response = serde_json::json!({
304            "status": "fail",
305            "message": t!("At least one split is required for a transaction"),
306        });
307        return Err((StatusCode::BAD_REQUEST, Json(error_response)));
308    }
309    Ok(())
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use num_rational::Rational64;
316
317    #[test]
318    fn test_parse_amount_integer() {
319        let (num, denom) = parse_amount_to_rational("25584").unwrap();
320        assert_eq!(Rational64::new(num, denom), Rational64::from_integer(25584));
321    }
322
323    #[test]
324    fn test_parse_amount_fractional() {
325        let (num, denom) = parse_amount_to_rational("153.81").unwrap();
326        let r = Rational64::new(num, denom);
327        assert_eq!(r, Rational64::new(15381, 100));
328    }
329
330    #[test]
331    fn test_conversion_price_jpy_usd() {
332        let from_id = Uuid::new_v4();
333        let to_id = Uuid::new_v4();
334        let from_commodity = Uuid::new_v4();
335        let to_commodity = Uuid::new_v4();
336
337        let (from_num, from_denom) = parse_amount_to_rational("25584").unwrap();
338        let (to_num, to_denom) = parse_amount_to_rational("153.81").unwrap();
339
340        let price = build_conversion_price(
341            from_id,
342            to_id,
343            from_commodity,
344            to_commodity,
345            from_num,
346            from_denom,
347            to_num,
348            to_denom,
349        );
350
351        let from_val = Rational64::new(-from_num, from_denom);
352        let to_val = Rational64::new(to_num, to_denom);
353        let conv_rate = Rational64::new(price.value_num, price.value_denom);
354
355        // commodity_split is to_split, so to_val * conv_rate must cancel from_val
356        assert_eq!(from_val + to_val * conv_rate, Rational64::from_integer(0));
357    }
358
359    #[test]
360    fn test_conversion_price_integer_amounts() {
361        let from_id = Uuid::new_v4();
362        let to_id = Uuid::new_v4();
363        let from_commodity = Uuid::new_v4();
364        let to_commodity = Uuid::new_v4();
365
366        let (from_num, from_denom) = parse_amount_to_rational("1000").unwrap();
367        let (to_num, to_denom) = parse_amount_to_rational("7").unwrap();
368
369        let price = build_conversion_price(
370            from_id,
371            to_id,
372            from_commodity,
373            to_commodity,
374            from_num,
375            from_denom,
376            to_num,
377            to_denom,
378        );
379
380        let from_val = Rational64::new(-from_num, from_denom);
381        let to_val = Rational64::new(to_num, to_denom);
382        let conv_rate = Rational64::new(price.value_num, price.value_denom);
383
384        assert_eq!(from_val + to_val * conv_rate, Rational64::from_integer(0));
385    }
386
387    #[test]
388    fn test_conversion_price_both_fractional() {
389        let from_id = Uuid::new_v4();
390        let to_id = Uuid::new_v4();
391        let from_commodity = Uuid::new_v4();
392        let to_commodity = Uuid::new_v4();
393
394        let (from_num, from_denom) = parse_amount_to_rational("99.50").unwrap();
395        let (to_num, to_denom) = parse_amount_to_rational("85.23").unwrap();
396
397        let price = build_conversion_price(
398            from_id,
399            to_id,
400            from_commodity,
401            to_commodity,
402            from_num,
403            from_denom,
404            to_num,
405            to_denom,
406        );
407
408        let from_val = Rational64::new(-from_num, from_denom);
409        let to_val = Rational64::new(to_num, to_denom);
410        let conv_rate = Rational64::new(price.value_num, price.value_denom);
411
412        assert_eq!(from_val + to_val * conv_rate, Rational64::from_integer(0));
413    }
414}