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 num_rational::Rational64;
7
use serde::Deserialize;
8
use server::command::{CmdResult, FinanceEntity, commodity::GetCommodity};
9
use sqlx::types::Uuid;
10
use std::collections::HashMap;
11

            
12
/// Common `SplitData` structure used by both create and edit
13
#[derive(Deserialize, Debug)]
14
pub 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)]
27
pub struct TagData {
28
    pub name: String,
29
    pub value: String,
30
    pub description: Option<String>,
31
}
32

            
33
/// Result of processing a single split
34
pub 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
43
pub 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
69
pub 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]
98
7
pub fn parse_transaction_date(date_str: Option<&str>) -> NaiveDateTime {
99
7
    date_str
100
7
        .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
101
7
        .map_or_else(|| Local::now().naive_utc(), |dt| dt.naive_utc())
102
7
}
103

            
104
/// Parse and validate UUID with custom error message
105
13
pub fn parse_uuid(
106
13
    uuid_str: &str,
107
13
    field_name: &str,
108
13
) -> Result<Uuid, (StatusCode, Json<serde_json::Value>)> {
109
13
    Uuid::parse_str(uuid_str).map_err(|_| {
110
1
        let error_response = serde_json::json!({
111
1
            "status": "fail",
112
1
            "message": format!("Invalid {}: {}", field_name, uuid_str),
113
        });
114
1
        (StatusCode::BAD_REQUEST, Json(error_response))
115
1
    })
116
13
}
117

            
118
/// Validate basic amount parsing and positivity (without precision checking)
119
10
pub fn validate_basic_amount(
120
10
    amount_str: &str,
121
10
) -> Result<f64, (StatusCode, Json<serde_json::Value>)> {
122
10
    let amount_value = amount_str.parse::<f64>().map_err(|_| {
123
1
        let error_response = serde_json::json!({
124
1
            "status": "fail",
125
1
            "message": format!("Invalid amount: {}", amount_str),
126
        });
127
1
        (StatusCode::BAD_REQUEST, Json(error_response))
128
1
    })?;
129

            
130
9
    if amount_value <= 0.0 {
131
2
        let error_response = serde_json::json!({
132
2
            "status": "fail",
133
2
            "message": t!("Split amount must be positive"),
134
        });
135
2
        return Err((StatusCode::BAD_REQUEST, Json(error_response)));
136
7
    }
137

            
138
7
    Ok(amount_value)
139
10
}
140

            
141
/// Parse amount string to rational number (numerator, denominator)
142
3
pub fn parse_amount_to_rational(
143
3
    amount_str: &str,
144
3
) -> Result<(i64, i64), (StatusCode, Json<serde_json::Value>)> {
145
3
    let amount_value = validate_basic_amount(amount_str)?;
146

            
147
3
    let rational = Rational64::approximate_float(amount_value).ok_or_else(|| {
148
        let error_response = serde_json::json!({
149
            "status": "fail",
150
            "message": format!("Cannot represent amount as rational: {}", amount_str),
151
        });
152
        (StatusCode::BAD_REQUEST, Json(error_response))
153
    })?;
154

            
155
3
    Ok((*rational.numer(), *rational.denom()))
156
3
}
157

            
158
/// Process a single split data into finance entities
159
7
pub async fn process_split_data(
160
7
    tx_id: Uuid,
161
7
    split_data: SplitData,
162
7
) -> Result<ProcessedSplit, (StatusCode, Json<serde_json::Value>)> {
163
7
    validate_basic_amount(&split_data.amount)?;
164

            
165
4
    let from_account_id = parse_uuid(&split_data.from_account, "from account ID")?;
166
3
    let to_account_id = parse_uuid(&split_data.to_account, "to account ID")?;
167
3
    let from_commodity = parse_uuid(&split_data.from_commodity, "from commodity ID")?;
168
3
    let to_commodity = parse_uuid(&split_data.to_commodity, "to commodity ID")?;
169

            
170
    // Only validate amount_converted if currency conversion is needed
171
3
    let conversion = from_commodity != to_commodity;
172
3
    if conversion {
173
        validate_basic_amount(&split_data.amount_converted)?;
174
3
    }
175

            
176
3
    let (from_num, from_denom) = parse_amount_to_rational(&split_data.amount)?;
177

            
178
3
    let from_split_id = Uuid::new_v4();
179
3
    let to_split_id = Uuid::new_v4();
180

            
181
3
    let (to_num, to_denom, price) = if conversion {
182
        let (to_num, to_denom) = parse_amount_to_rational(&split_data.amount_converted)?;
183

            
184
        let price = Price {
185
            id: Uuid::new_v4(),
186
            date: chrono::Utc::now(),
187
            commodity_id: to_commodity,
188
            currency_id: from_commodity,
189
            commodity_split: Some(to_split_id),
190
            currency_split: Some(from_split_id),
191
            value_num: from_num,
192
            value_denom: to_num,
193
        };
194

            
195
        (to_num, to_denom, Some(price))
196
    } else {
197
3
        (from_num, from_denom, None)
198
    };
199

            
200
    // Create split entities
201
3
    let from_split = finance::split::Split {
202
3
        id: from_split_id,
203
3
        tx_id,
204
3
        account_id: from_account_id,
205
3
        commodity_id: from_commodity,
206
3
        value_num: -from_num,
207
3
        value_denom: from_denom,
208
3
        reconcile_state: None,
209
3
        reconcile_date: None,
210
3
        lot_id: None,
211
3
    };
212

            
213
3
    let to_split = finance::split::Split {
214
3
        id: to_split_id,
215
3
        tx_id,
216
3
        account_id: to_account_id,
217
3
        commodity_id: to_commodity,
218
3
        value_num: to_num,
219
3
        value_denom: to_denom,
220
3
        reconcile_state: None,
221
3
        reconcile_date: None,
222
3
        lot_id: None,
223
3
    };
224

            
225
3
    Ok(ProcessedSplit {
226
3
        from_split,
227
3
        to_split,
228
3
        price,
229
3
        from_split_tags: split_data.from_tags,
230
3
        to_split_tags: split_data.to_tags,
231
3
    })
232
7
}
233

            
234
/// Create transaction tags from note
235
#[must_use]
236
pub fn create_transaction_tags(note: Option<&str>) -> HashMap<String, FinanceEntity> {
237
    let mut tags = HashMap::new();
238
    if let Some(note_str) = note
239
        && !note_str.trim().is_empty()
240
    {
241
        let tag = finance::tag::Tag {
242
            id: Uuid::new_v4(),
243
            tag_name: "note".to_string(),
244
            tag_value: note_str.to_string(),
245
            description: None,
246
        };
247
        tags.insert("note".to_string(), FinanceEntity::Tag(tag));
248
    }
249
    tags
250
}
251

            
252
/// Validate that splits are not empty
253
8
pub fn validate_splits_not_empty<T>(
254
8
    splits: &[T],
255
8
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
256
8
    if splits.is_empty() {
257
1
        let error_response = serde_json::json!({
258
1
            "status": "fail",
259
1
            "message": t!("At least one split is required for a transaction"),
260
        });
261
1
        return Err((StatusCode::BAD_REQUEST, Json(error_response)));
262
7
    }
263
7
    Ok(())
264
8
}