1
//web/src/pages/transaction/util.rs - Shared utility functions for transaction operations
2

            
3
use axum::{Json, http::StatusCode};
4
use chrono::{Local, NaiveDateTime};
5
use finance::price::Price;
6
use serde::Deserialize;
7
use server::command::{CmdResult, FinanceEntity, commodity::GetCommodity};
8
use sqlx::types::Uuid;
9
use std::collections::HashMap;
10

            
11
/// Common SplitData structure used by both create and edit
12
#[derive(Deserialize, Debug)]
13
pub struct SplitData {
14
    pub split_id: Option<String>, // Only used by edit
15
    pub amount: String,
16
    pub amount_converted: String,
17
    pub from_account: String,
18
    pub to_account: String,
19
    pub from_commodity: String,
20
    pub to_commodity: String,
21
    pub from_tags: Option<Vec<TagData>>,
22
    pub to_tags: Option<Vec<TagData>>,
23
}
24

            
25
#[derive(Deserialize, Debug)]
26
pub struct TagData {
27
    pub name: String,
28
    pub value: String,
29
    pub description: Option<String>,
30
}
31

            
32
/// Result of processing a single split
33
pub struct ProcessedSplit {
34
    pub from_split: finance::split::Split,
35
    pub to_split: finance::split::Split,
36
    pub price: Option<Price>,
37
    pub from_split_tags: Option<Vec<TagData>>,
38
    pub to_split_tags: Option<Vec<TagData>>,
39
}
40

            
41
/// Get commodity fraction for precise calculations
42
6
pub async fn get_commodity_fraction(
43
6
    user_id: Uuid,
44
6
    commodity_id: Uuid,
45
9
) -> Result<i64, (StatusCode, Json<serde_json::Value>)> {
46
6
    match GetCommodity::new()
47
6
        .user_id(user_id)
48
6
        .commodity_id(commodity_id)
49
6
        .run()
50
6
        .await
51
    {
52
        Ok(Some(CmdResult::TaggedEntities(commodities))) => {
53
            if let Some((FinanceEntity::Commodity(c), _)) = commodities.first() {
54
                Ok(c.fraction)
55
            } else {
56
                let error_response = serde_json::json!({
57
                    "status": "fail",
58
                    "message": "Commodity not found",
59
                });
60
                log::error!("Commodity not found");
61
                Err((StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)))
62
            }
63
        }
64
        _ => {
65
6
            let error_response = serde_json::json!({
66
6
                "status": "fail",
67
6
                "message": "Commodity not found",
68
            });
69
6
            log::error!("Commodity not found");
70
6
            Err((StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)))
71
        }
72
    }
73
6
}
74

            
75
/// Get account name by ID
76
pub async fn get_account_name(
77
    user_id: Uuid,
78
    account_id: Uuid,
79
) -> Result<String, Box<dyn std::error::Error>> {
80
    match server::command::account::GetAccount::new()
81
        .user_id(user_id)
82
        .account_id(account_id)
83
        .run()
84
        .await?
85
    {
86
        Some(CmdResult::TaggedEntities(entities)) => {
87
            if let Some((FinanceEntity::Account(_account), tags)) = entities.first() {
88
                if let Some(FinanceEntity::Tag(name_tag)) = tags.get("name") {
89
                    Ok(name_tag.tag_value.clone())
90
                } else {
91
                    Ok("Unnamed Account".to_string())
92
                }
93
            } else {
94
                Ok("Unknown Account".to_string())
95
            }
96
        }
97
        _ => Ok("Unknown Account".to_string()),
98
    }
99
}
100

            
101
/// Get commodity name (symbol or name) by ID
102
pub async fn get_commodity_name(
103
    user_id: Uuid,
104
    commodity_id: Uuid,
105
) -> Result<String, Box<dyn std::error::Error>> {
106
    match GetCommodity::new()
107
        .user_id(user_id)
108
        .commodity_id(commodity_id)
109
        .run()
110
        .await?
111
    {
112
        Some(CmdResult::TaggedEntities(entities)) => {
113
            if let Some((FinanceEntity::Commodity(_commodity), tags)) = entities.first() {
114
                if let Some(FinanceEntity::Tag(name_tag)) = tags.get("name") {
115
                    Ok(name_tag.tag_value.clone())
116
                } else if let Some(FinanceEntity::Tag(symbol_tag)) = tags.get("symbol") {
117
                    Ok(symbol_tag.tag_value.clone())
118
                } else {
119
                    Ok("Unknown Currency".to_string())
120
                }
121
            } else {
122
                Ok("Unknown Currency".to_string())
123
            }
124
        }
125
        _ => Ok("Unknown Currency".to_string()),
126
    }
127
}
128

            
129
/// Parse date string or return current time
130
14
pub fn parse_transaction_date(date_str: Option<&str>) -> NaiveDateTime {
131
14
    if let Some(datetime_str) = date_str {
132
6
        match chrono::DateTime::parse_from_rfc3339(datetime_str) {
133
6
            Ok(datetime) => datetime.naive_utc(),
134
            Err(_) => Local::now().naive_utc(),
135
        }
136
    } else {
137
8
        Local::now().naive_utc()
138
    }
139
14
}
140

            
141
/// Parse and validate UUID with custom error message
142
26
pub fn parse_uuid(
143
26
    uuid_str: &str,
144
26
    field_name: &str,
145
26
) -> Result<Uuid, (StatusCode, Json<serde_json::Value>)> {
146
27
    Uuid::parse_str(uuid_str).map_err(|_| {
147
2
        let error_response = serde_json::json!({
148
2
            "status": "fail",
149
2
            "message": format!("Invalid {}: {}", field_name, uuid_str),
150
        });
151
2
        (StatusCode::BAD_REQUEST, Json(error_response))
152
2
    })
153
26
}
154

            
155
/// Validate basic amount parsing and positivity (without precision checking)
156
14
pub fn validate_basic_amount(
157
14
    amount_str: &str,
158
14
) -> Result<f64, (StatusCode, Json<serde_json::Value>)> {
159
15
    let amount_value = amount_str.parse::<f64>().map_err(|_| {
160
2
        let error_response = serde_json::json!({
161
2
            "status": "fail",
162
2
            "message": format!("Invalid amount: {}", amount_str),
163
        });
164
2
        (StatusCode::BAD_REQUEST, Json(error_response))
165
3
    })?;
166

            
167
12
    if amount_value <= 0.0 {
168
4
        let error_response = serde_json::json!({
169
4
            "status": "fail",
170
4
            "message": t!("Split amount must be positive"),
171
        });
172
4
        return Err((StatusCode::BAD_REQUEST, Json(error_response)));
173
8
    }
174

            
175
8
    Ok(amount_value)
176
14
}
177

            
178
/// Validate and convert amount with precision checking
179
pub fn validate_and_convert_amount(
180
    amount_str: &str,
181
    fraction: i64,
182
) -> Result<i64, (StatusCode, Json<serde_json::Value>)> {
183
    let amount_value = validate_basic_amount(amount_str)?;
184

            
185
    let converted_amount = (amount_value * fraction as f64).round() as i64;
186
    let reconverted_value = converted_amount as f64 / fraction as f64;
187
    let epsilon = 0.5 / fraction as f64;
188

            
189
    if (reconverted_value - amount_value).abs() > epsilon {
190
        let error_response = serde_json::json!({
191
            "status": "fail",
192
            "message": format!("Precision loss detected: {} cannot be precisely represented with fraction {}", amount_str, fraction),
193
        });
194
        return Err((StatusCode::BAD_REQUEST, Json(error_response)));
195
    }
196

            
197
    Ok(converted_amount)
198
}
199

            
200
/// Process a single split data into finance entities
201
14
pub async fn process_split_data(
202
14
    user_id: Uuid,
203
14
    tx_id: Uuid,
204
14
    split_data: SplitData,
205
21
) -> Result<ProcessedSplit, (StatusCode, Json<serde_json::Value>)> {
206
14
    validate_basic_amount(&split_data.amount)?;
207

            
208
8
    let from_account_id = parse_uuid(&split_data.from_account, "from account ID")?;
209
6
    let to_account_id = parse_uuid(&split_data.to_account, "to account ID")?;
210
6
    let from_commodity = parse_uuid(&split_data.from_commodity, "from commodity ID")?;
211
6
    let to_commodity = parse_uuid(&split_data.to_commodity, "to commodity ID")?;
212

            
213
    // Only validate amount_converted if currency conversion is needed
214
6
    let conversion = from_commodity != to_commodity;
215
6
    if conversion {
216
        validate_basic_amount(&split_data.amount_converted)?;
217
6
    }
218

            
219
    // Get commodity fractions
220
6
    let from_fraction = get_commodity_fraction(user_id, from_commodity).await?;
221
    let to_fraction = get_commodity_fraction(user_id, to_commodity).await?;
222

            
223
    // Validate and convert amounts with full precision checking
224
    let from_amount = validate_and_convert_amount(&split_data.amount, from_fraction)?;
225

            
226
    let from_split_id = Uuid::new_v4();
227
    let to_split_id = Uuid::new_v4();
228

            
229
    let (converted_to_amount, price) = if conversion {
230
        let to_amount = validate_and_convert_amount(&split_data.amount_converted, to_fraction)?;
231

            
232
        let price = Price {
233
            id: Uuid::new_v4(),
234
            date: chrono::Utc::now(),
235
            commodity_id: to_commodity,
236
            currency_id: from_commodity,
237
            commodity_split: Some(to_split_id),
238
            currency_split: Some(from_split_id),
239
            value_num: from_amount,
240
            value_denom: to_amount,
241
        };
242

            
243
        (to_amount, Some(price))
244
    } else {
245
        (from_amount, None)
246
    };
247

            
248
    // Create split entities
249
    let from_split = finance::split::Split {
250
        id: from_split_id,
251
        tx_id,
252
        account_id: from_account_id,
253
        commodity_id: from_commodity,
254
        value_num: -from_amount,
255
        value_denom: from_fraction,
256
        reconcile_state: None,
257
        reconcile_date: None,
258
        lot_id: None,
259
    };
260

            
261
    let to_split = finance::split::Split {
262
        id: to_split_id,
263
        tx_id,
264
        account_id: to_account_id,
265
        commodity_id: to_commodity,
266
        value_num: if conversion {
267
            converted_to_amount
268
        } else {
269
            from_amount
270
        },
271
        value_denom: if conversion {
272
            to_fraction
273
        } else {
274
            from_fraction
275
        },
276
        reconcile_state: None,
277
        reconcile_date: None,
278
        lot_id: None,
279
    };
280

            
281
    Ok(ProcessedSplit {
282
        from_split,
283
        to_split,
284
        price,
285
        from_split_tags: split_data.from_tags,
286
        to_split_tags: split_data.to_tags,
287
    })
288
14
}
289

            
290
/// Create transaction tags from note
291
pub fn create_transaction_tags(note: Option<&str>) -> HashMap<String, FinanceEntity> {
292
    let mut tags = HashMap::new();
293
    if let Some(note_str) = note
294
        && !note_str.trim().is_empty()
295
    {
296
        let tag = finance::tag::Tag {
297
            id: Uuid::new_v4(),
298
            tag_name: "note".to_string(),
299
            tag_value: note_str.to_string(),
300
            description: None,
301
        };
302
        tags.insert("note".to_string(), FinanceEntity::Tag(tag));
303
    }
304
    tags
305
}
306

            
307
/// Validate that splits are not empty
308
16
pub fn validate_splits_not_empty<T>(
309
16
    splits: &[T],
310
16
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
311
16
    if splits.is_empty() {
312
2
        let error_response = serde_json::json!({
313
2
            "status": "fail",
314
2
            "message": t!("At least one split is required for a transaction"),
315
        });
316
2
        return Err((StatusCode::BAD_REQUEST, Json(error_response)));
317
14
    }
318
14
    Ok(())
319
16
}