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
3
pub async fn get_commodity_fraction(
43
3
    user_id: Uuid,
44
3
    commodity_id: Uuid,
45
3
) -> Result<i64, (StatusCode, Json<serde_json::Value>)> {
46
    if let Ok(Some(CmdResult::TaggedEntities {
47
        entities: commodities,
48
        ..
49
3
    })) = GetCommodity::new()
50
3
        .user_id(user_id)
51
3
        .commodity_id(commodity_id)
52
3
        .run()
53
3
        .await
54
    {
55
        if let Some((FinanceEntity::Commodity(c), _)) = commodities.first() {
56
            Ok(c.fraction)
57
        } else {
58
            let error_response = serde_json::json!({
59
                "status": "fail",
60
                "message": "Commodity not found",
61
            });
62
            log::error!("Commodity not found");
63
            Err((StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)))
64
        }
65
    } else {
66
3
        let error_response = serde_json::json!({
67
3
            "status": "fail",
68
3
            "message": "Commodity not found",
69
        });
70
3
        log::error!("Commodity not found");
71
3
        Err((StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)))
72
    }
73
3
}
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 symbol (or name as fallback) 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(symbol_tag)) = tags.get("symbol") {
115
                    Ok(symbol_tag.tag_value.clone())
116
                } else if let Some(FinanceEntity::Tag(name_tag)) = tags.get("name") {
117
                    Ok(name_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
#[must_use]
131
7
pub fn parse_transaction_date(date_str: Option<&str>) -> NaiveDateTime {
132
7
    if let Some(datetime_str) = date_str {
133
3
        match chrono::DateTime::parse_from_rfc3339(datetime_str) {
134
3
            Ok(datetime) => datetime.naive_utc(),
135
            Err(_) => Local::now().naive_utc(),
136
        }
137
    } else {
138
4
        Local::now().naive_utc()
139
    }
140
7
}
141

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

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

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

            
176
4
    Ok(amount_value)
177
7
}
178

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

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

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

            
198
    Ok(converted_amount)
199
}
200

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

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

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

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

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

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

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

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

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

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

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

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

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

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