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
/// Parses a transaction date submitted by the create/edit form.
97
///
98
/// The browser's `datetime-local` control (and the default-date helper's
99
/// `toISOString().slice(0,16)`) submit `YYYY-MM-DDTHH:MM` — NOT RFC3339 — so
100
/// accepting only RFC3339 silently dropped every entered date to "now". Accept
101
/// the `datetime-local` shapes AND RFC3339; only a genuinely absent/empty/
102
/// unparseable value falls back to the current time.
103
#[must_use]
104
19
pub fn parse_transaction_date(date_str: Option<&str>) -> NaiveDateTime {
105
19
    date_str
106
19
        .map(str::trim)
107
19
        .filter(|s| !s.is_empty())
108
19
        .and_then(parse_form_datetime)
109
19
        .unwrap_or_else(|| Local::now().naive_utc())
110
19
}
111

            
112
/// Parses the date shapes the form can submit, in order of likelihood:
113
/// `datetime-local` (`YYYY-MM-DDTHH:MM[:SS]`), a bare date, or RFC3339.
114
11
fn parse_form_datetime(s: &str) -> Option<NaiveDateTime> {
115
    use chrono::{NaiveDate, NaiveDateTime};
116
20
    for fmt in ["%Y-%m-%dT%H:%M", "%Y-%m-%dT%H:%M:%S"] {
117
20
        if let Ok(dt) = NaiveDateTime::parse_from_str(s, fmt) {
118
4
            return Some(dt);
119
16
        }
120
    }
121
7
    if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
122
2
        return d.and_hms_opt(0, 0, 0);
123
5
    }
124
5
    chrono::DateTime::parse_from_rfc3339(s)
125
5
        .ok()
126
5
        .map(|dt| dt.naive_utc())
127
11
}
128

            
129
/// Parse and validate UUID with custom error message
130
13
pub fn parse_uuid(
131
13
    uuid_str: &str,
132
13
    field_name: &str,
133
13
) -> Result<Uuid, (StatusCode, Json<serde_json::Value>)> {
134
13
    Uuid::parse_str(uuid_str).map_err(|_| {
135
1
        let error_response = serde_json::json!({
136
1
            "status": "fail",
137
1
            "message": format!("Invalid {}: {}", field_name, uuid_str),
138
        });
139
1
        (StatusCode::BAD_REQUEST, Json(error_response))
140
1
    })
141
13
}
142

            
143
/// Validate basic amount parsing and positivity (without precision checking)
144
26
pub fn validate_basic_amount(
145
26
    amount_str: &str,
146
26
) -> Result<f64, (StatusCode, Json<serde_json::Value>)> {
147
26
    let amount_value = amount_str.parse::<f64>().map_err(|_| {
148
1
        let error_response = serde_json::json!({
149
1
            "status": "fail",
150
1
            "message": format!("Invalid amount: {}", amount_str),
151
        });
152
1
        (StatusCode::BAD_REQUEST, Json(error_response))
153
1
    })?;
154

            
155
25
    if amount_value <= 0.0 {
156
2
        let error_response = serde_json::json!({
157
2
            "status": "fail",
158
2
            "message": t!("Split amount must be positive"),
159
        });
160
2
        return Err((StatusCode::BAD_REQUEST, Json(error_response)));
161
23
    }
162

            
163
23
    Ok(amount_value)
164
26
}
165

            
166
/// Parse a decimal string directly into an exact rational (numerator, denominator).
167
///
168
/// `"153.81"` becomes `(15381, 100)` — no floating-point intermediary.
169
19
pub fn parse_amount_to_rational(
170
19
    amount_str: &str,
171
19
) -> Result<(i64, i64), (StatusCode, Json<serde_json::Value>)> {
172
19
    validate_basic_amount(amount_str)?;
173

            
174
19
    let trimmed = amount_str.trim();
175
19
    let (numer, denom) = if let Some(dot_pos) = trimmed.find('.') {
176
11
        let decimals = trimmed.len() - dot_pos - 1;
177
62
        let without_dot: String = trimmed.chars().filter(|c| *c != '.').collect();
178
11
        let n = without_dot.parse::<i64>().map_err(|_| {
179
            let error_response = serde_json::json!({
180
                "status": "fail",
181
                "message": format!("Cannot represent amount as rational: {}", amount_str),
182
            });
183
            (StatusCode::BAD_REQUEST, Json(error_response))
184
        })?;
185
11
        (n, 10_i64.pow(decimals as u32))
186
    } else {
187
8
        let n = trimmed.parse::<i64>().map_err(|_| {
188
            let error_response = serde_json::json!({
189
                "status": "fail",
190
                "message": format!("Cannot represent amount as rational: {}", amount_str),
191
            });
192
            (StatusCode::BAD_REQUEST, Json(error_response))
193
        })?;
194
8
        (n, 1)
195
    };
196

            
197
19
    let r = Rational64::new(numer, denom);
198
19
    Ok((*r.numer(), *r.denom()))
199
19
}
200

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

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

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

            
219
3
    let (from_num, from_denom) = parse_amount_to_rational(&split_data.amount)?;
220

            
221
3
    let from_split_id = Uuid::new_v4();
222
3
    let to_split_id = Uuid::new_v4();
223

            
224
3
    let (to_num, to_denom, price) = if conversion {
225
        let (to_num, to_denom) = parse_amount_to_rational(&split_data.amount_converted)?;
226

            
227
        let price = build_conversion_price(
228
            from_split_id,
229
            to_split_id,
230
            from_commodity,
231
            to_commodity,
232
            from_num,
233
            from_denom,
234
            to_num,
235
            to_denom,
236
        );
237

            
238
        (to_num, to_denom, Some(price))
239
    } else {
240
3
        (from_num, from_denom, None)
241
    };
242

            
243
    // Create split entities
244
3
    let from_split = finance::split::Split {
245
3
        id: from_split_id,
246
3
        tx_id,
247
3
        account_id: from_account_id,
248
3
        commodity_id: from_commodity,
249
3
        value_num: -from_num,
250
3
        value_denom: from_denom,
251
3
        reconcile_state: None,
252
3
        reconcile_date: None,
253
3
        lot_id: None,
254
3
    };
255

            
256
3
    let to_split = finance::split::Split {
257
3
        id: to_split_id,
258
3
        tx_id,
259
3
        account_id: to_account_id,
260
3
        commodity_id: to_commodity,
261
3
        value_num: to_num,
262
3
        value_denom: to_denom,
263
3
        reconcile_state: None,
264
3
        reconcile_date: None,
265
3
        lot_id: None,
266
3
    };
267

            
268
3
    Ok(ProcessedSplit {
269
3
        from_split,
270
3
        to_split,
271
3
        price,
272
3
        from_split_tags: split_data.from_tags,
273
3
        to_split_tags: split_data.to_tags,
274
3
    })
275
7
}
276

            
277
/// Create transaction tags from note
278
#[must_use]
279
pub fn create_transaction_tags(note: Option<&str>) -> HashMap<String, FinanceEntity> {
280
    let mut tags = HashMap::new();
281
    if let Some(note_str) = note
282
        && !note_str.trim().is_empty()
283
    {
284
        let tag = finance::tag::Tag {
285
            id: Uuid::new_v4(),
286
            tag_name: "note".to_string(),
287
            tag_value: note_str.to_string(),
288
            description: None,
289
        };
290
        tags.insert("note".to_string(), FinanceEntity::Tag(tag));
291
    }
292
    tags
293
}
294

            
295
/// Build a Price for a multi-currency split from rational components.
296
///
297
/// `from` is the spent amount (will be negated in the split),
298
/// `to` is the received amount in a different commodity.
299
/// Returns a Price whose rate converts `to` back to `from` units.
300
#[must_use]
301
6
pub fn build_conversion_price(
302
6
    from_split_id: Uuid,
303
6
    to_split_id: Uuid,
304
6
    from_commodity: Uuid,
305
6
    to_commodity: Uuid,
306
6
    from_num: i64,
307
6
    from_denom: i64,
308
6
    to_num: i64,
309
6
    to_denom: i64,
310
6
) -> Price {
311
6
    Price {
312
6
        id: Uuid::new_v4(),
313
6
        date: chrono::Utc::now(),
314
6
        commodity_id: to_commodity,
315
6
        currency_id: from_commodity,
316
6
        commodity_split: Some(to_split_id),
317
6
        currency_split: Some(from_split_id),
318
6
        value_num: from_num * to_denom,
319
6
        value_denom: from_denom * to_num,
320
6
    }
321
6
}
322

            
323
/// Validate that splits are not empty
324
8
pub fn validate_splits_not_empty<T>(
325
8
    splits: &[T],
326
8
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
327
8
    if splits.is_empty() {
328
1
        let error_response = serde_json::json!({
329
1
            "status": "fail",
330
1
            "message": t!("At least one split is required for a transaction"),
331
        });
332
1
        return Err((StatusCode::BAD_REQUEST, Json(error_response)));
333
7
    }
334
7
    Ok(())
335
8
}
336

            
337
#[cfg(test)]
338
mod tests {
339
    use super::*;
340
    use num_rational::Rational64;
341

            
342
    #[test]
343
2
    fn test_parse_amount_integer() {
344
2
        let (num, denom) = parse_amount_to_rational("25584").unwrap();
345
2
        assert_eq!(Rational64::new(num, denom), Rational64::from_integer(25584));
346
2
    }
347

            
348
    #[test]
349
2
    fn parse_transaction_date_accepts_datetime_local() {
350
        // The datetime-local control submits this shape; it must NOT fall back
351
        // to "now" (the pre-existing RFC3339-only bug).
352
2
        let dt = parse_transaction_date(Some("2026-06-15T09:30"));
353
2
        assert_eq!(dt.format("%Y-%m-%dT%H:%M").to_string(), "2026-06-15T09:30");
354
2
        let with_secs = parse_transaction_date(Some("2026-06-15T09:30:45"));
355
2
        assert_eq!(with_secs.format("%H:%M:%S").to_string(), "09:30:45");
356
2
    }
357

            
358
    #[test]
359
2
    fn parse_transaction_date_accepts_bare_date_and_rfc3339() {
360
2
        let bare = parse_transaction_date(Some("2026-06-15"));
361
2
        assert_eq!(
362
2
            bare.format("%Y-%m-%dT%H:%M").to_string(),
363
            "2026-06-15T00:00"
364
        );
365
2
        let rfc = parse_transaction_date(Some("2026-06-15T09:30:00+00:00"));
366
2
        assert_eq!(rfc.format("%Y-%m-%dT%H:%M").to_string(), "2026-06-15T09:30");
367
2
    }
368

            
369
    #[test]
370
2
    fn parse_transaction_date_empty_or_missing_uses_now() {
371
        // Only a genuinely absent/blank value falls back; a parseable date never
372
        // silently becomes "now".
373
2
        let a = parse_transaction_date(None);
374
2
        let b = parse_transaction_date(Some("   "));
375
2
        let now = Local::now().naive_utc();
376
2
        assert!((now - a).num_seconds().abs() < 5);
377
2
        assert!((now - b).num_seconds().abs() < 5);
378
2
    }
379

            
380
    #[test]
381
2
    fn test_parse_amount_fractional() {
382
2
        let (num, denom) = parse_amount_to_rational("153.81").unwrap();
383
2
        let r = Rational64::new(num, denom);
384
2
        assert_eq!(r, Rational64::new(15381, 100));
385
2
    }
386

            
387
    #[test]
388
2
    fn test_conversion_price_jpy_usd() {
389
2
        let from_id = Uuid::new_v4();
390
2
        let to_id = Uuid::new_v4();
391
2
        let from_commodity = Uuid::new_v4();
392
2
        let to_commodity = Uuid::new_v4();
393

            
394
2
        let (from_num, from_denom) = parse_amount_to_rational("25584").unwrap();
395
2
        let (to_num, to_denom) = parse_amount_to_rational("153.81").unwrap();
396

            
397
2
        let price = build_conversion_price(
398
2
            from_id,
399
2
            to_id,
400
2
            from_commodity,
401
2
            to_commodity,
402
2
            from_num,
403
2
            from_denom,
404
2
            to_num,
405
2
            to_denom,
406
        );
407

            
408
2
        let from_val = Rational64::new(-from_num, from_denom);
409
2
        let to_val = Rational64::new(to_num, to_denom);
410
2
        let conv_rate = Rational64::new(price.value_num, price.value_denom);
411

            
412
        // commodity_split is to_split, so to_val * conv_rate must cancel from_val
413
2
        assert_eq!(from_val + to_val * conv_rate, Rational64::from_integer(0));
414
2
    }
415

            
416
    #[test]
417
2
    fn test_conversion_price_integer_amounts() {
418
2
        let from_id = Uuid::new_v4();
419
2
        let to_id = Uuid::new_v4();
420
2
        let from_commodity = Uuid::new_v4();
421
2
        let to_commodity = Uuid::new_v4();
422

            
423
2
        let (from_num, from_denom) = parse_amount_to_rational("1000").unwrap();
424
2
        let (to_num, to_denom) = parse_amount_to_rational("7").unwrap();
425

            
426
2
        let price = build_conversion_price(
427
2
            from_id,
428
2
            to_id,
429
2
            from_commodity,
430
2
            to_commodity,
431
2
            from_num,
432
2
            from_denom,
433
2
            to_num,
434
2
            to_denom,
435
        );
436

            
437
2
        let from_val = Rational64::new(-from_num, from_denom);
438
2
        let to_val = Rational64::new(to_num, to_denom);
439
2
        let conv_rate = Rational64::new(price.value_num, price.value_denom);
440

            
441
2
        assert_eq!(from_val + to_val * conv_rate, Rational64::from_integer(0));
442
2
    }
443

            
444
    #[test]
445
2
    fn test_conversion_price_both_fractional() {
446
2
        let from_id = Uuid::new_v4();
447
2
        let to_id = Uuid::new_v4();
448
2
        let from_commodity = Uuid::new_v4();
449
2
        let to_commodity = Uuid::new_v4();
450

            
451
2
        let (from_num, from_denom) = parse_amount_to_rational("99.50").unwrap();
452
2
        let (to_num, to_denom) = parse_amount_to_rational("85.23").unwrap();
453

            
454
2
        let price = build_conversion_price(
455
2
            from_id,
456
2
            to_id,
457
2
            from_commodity,
458
2
            to_commodity,
459
2
            from_num,
460
2
            from_denom,
461
2
            to_num,
462
2
            to_denom,
463
        );
464

            
465
2
        let from_val = Rational64::new(-from_num, from_denom);
466
2
        let to_val = Rational64::new(to_num, to_denom);
467
2
        let conv_rate = Rational64::new(price.value_num, price.value_denom);
468

            
469
2
        assert_eq!(from_val + to_val * conv_rate, Rational64::from_integer(0));
470
2
    }
471
}