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

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

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

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

            
40
/// Get account name by ID
41
pub async fn get_account_name(
42
    user_id: Uuid,
43
    account_id: Uuid,
44
) -> Result<String, Box<dyn std::error::Error>> {
45
    match server::command::account::GetAccount::new()
46
        .user_id(user_id)
47
        .account_id(account_id)
48
        .run()
49
        .await?
50
    {
51
        Some(CmdResult::TaggedEntities { entities, .. }) => {
52
            if let Some((FinanceEntity::Account(_account), tags)) = entities.first() {
53
                if let Some(FinanceEntity::Tag(name_tag)) = tags.get("name") {
54
                    Ok(name_tag.tag_value.clone())
55
                } else {
56
                    Ok("Unnamed Account".to_string())
57
                }
58
            } else {
59
                Ok("Unknown Account".to_string())
60
            }
61
        }
62
        _ => Ok("Unknown Account".to_string()),
63
    }
64
}
65

            
66
/// Get commodity symbol (or name as fallback) by ID
67
pub async fn get_commodity_name(
68
    user_id: Uuid,
69
    commodity_id: Uuid,
70
) -> Result<String, Box<dyn std::error::Error>> {
71
    match GetCommodity::new()
72
        .user_id(user_id)
73
        .commodity_id(commodity_id)
74
        .run()
75
        .await?
76
    {
77
        Some(CmdResult::TaggedEntities { entities, .. }) => {
78
            if let Some((FinanceEntity::Commodity(_commodity), tags)) = entities.first() {
79
                if let Some(FinanceEntity::Tag(symbol_tag)) = tags.get("symbol") {
80
                    Ok(symbol_tag.tag_value.clone())
81
                } else if let Some(FinanceEntity::Tag(name_tag)) = tags.get("name") {
82
                    Ok(name_tag.tag_value.clone())
83
                } else {
84
                    Ok("Unknown Currency".to_string())
85
                }
86
            } else {
87
                Ok("Unknown Currency".to_string())
88
            }
89
        }
90
        _ => Ok("Unknown Currency".to_string()),
91
    }
92
}
93

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

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

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

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

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

            
161
25
    Ok(amount_value)
162
29
}
163

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

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

            
195
21
    let r = Rational64::new(numer, denom);
196
21
    Ok((*r.numer(), *r.denom()))
197
22
}
198

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

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

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

            
217
3
    let (from_num, from_denom) = parse_amount_to_rational(&split_data.amount)?;
218

            
219
3
    let from_split_id = Uuid::new_v4();
220
3
    let to_split_id = Uuid::new_v4();
221

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

            
225
        let price = build_conversion_price(
226
            &ConversionSide {
227
                split_id: from_split_id,
228
                commodity: from_commodity,
229
                num: from_num,
230
                denom: from_denom,
231
            },
232
            &ConversionSide {
233
                split_id: to_split_id,
234
                commodity: to_commodity,
235
                num: to_num,
236
                denom: to_denom,
237
            },
238
        );
239

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

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

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

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

            
279
/// Build a Price for a multi-currency split from rational components.
280
///
281
/// `from` is the spent amount (will be negated in the split),
282
/// `to` is the received amount in a different commodity.
283
/// Returns a Price whose rate converts `to` back to `from` units.
284
/// One side of a multi-currency conversion: which split, in which commodity,
285
/// for how much. The two sides are symmetric, which is why they share a type.
286
pub struct ConversionSide {
287
    pub split_id: Uuid,
288
    pub commodity: Uuid,
289
    pub num: i64,
290
    pub denom: i64,
291
}
292

            
293
#[must_use]
294
6
pub fn build_conversion_price(from: &ConversionSide, to: &ConversionSide) -> Price {
295
6
    Price {
296
6
        id: Uuid::new_v4(),
297
6
        date: chrono::Utc::now(),
298
6
        commodity_id: to.commodity,
299
6
        currency_id: from.commodity,
300
6
        commodity_split: Some(to.split_id),
301
6
        currency_split: Some(from.split_id),
302
6
        value_num: from.num * to.denom,
303
6
        value_denom: from.denom * to.num,
304
6
    }
305
6
}
306

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

            
321
#[cfg(test)]
322
mod tests {
323
    use super::*;
324
    use num_rational::Rational64;
325

            
326
    #[test]
327
2
    fn test_parse_amount_integer() {
328
2
        let (num, denom) = parse_amount_to_rational("25584").unwrap();
329
2
        assert_eq!(Rational64::new(num, denom), Rational64::from_integer(25584));
330
2
    }
331

            
332
    #[test]
333
2
    fn parse_transaction_date_accepts_datetime_local() {
334
        // The datetime-local control submits this shape; it must NOT fall back
335
        // to "now" (the pre-existing RFC3339-only bug).
336
2
        let dt = parse_transaction_date(Some("2026-06-15T09:30"));
337
2
        assert_eq!(dt.format("%Y-%m-%dT%H:%M").to_string(), "2026-06-15T09:30");
338
2
        let with_secs = parse_transaction_date(Some("2026-06-15T09:30:45"));
339
2
        assert_eq!(with_secs.format("%H:%M:%S").to_string(), "09:30:45");
340
2
    }
341

            
342
    #[test]
343
2
    fn parse_transaction_date_accepts_bare_date_and_rfc3339() {
344
2
        let bare = parse_transaction_date(Some("2026-06-15"));
345
2
        assert_eq!(
346
2
            bare.format("%Y-%m-%dT%H:%M").to_string(),
347
            "2026-06-15T00:00"
348
        );
349
2
        let rfc = parse_transaction_date(Some("2026-06-15T09:30:00+00:00"));
350
2
        assert_eq!(rfc.format("%Y-%m-%dT%H:%M").to_string(), "2026-06-15T09:30");
351
2
    }
352

            
353
    #[test]
354
2
    fn parse_transaction_date_empty_or_missing_uses_now() {
355
        // Only a genuinely absent/blank value falls back; a parseable date never
356
        // silently becomes "now".
357
2
        let a = parse_transaction_date(None);
358
2
        let b = parse_transaction_date(Some("   "));
359
2
        let now = Local::now().naive_utc();
360
2
        assert!((now - a).num_seconds().abs() < 5);
361
2
        assert!((now - b).num_seconds().abs() < 5);
362
2
    }
363

            
364
    #[test]
365
2
    fn test_parse_amount_fractional() {
366
2
        let (num, denom) = parse_amount_to_rational("153.81").unwrap();
367
2
        let r = Rational64::new(num, denom);
368
2
        assert_eq!(r, Rational64::new(15381, 100));
369
2
    }
370

            
371
    #[test]
372
2
    fn test_conversion_price_jpy_usd() {
373
2
        let from_id = Uuid::new_v4();
374
2
        let to_id = Uuid::new_v4();
375
2
        let from_commodity = Uuid::new_v4();
376
2
        let to_commodity = Uuid::new_v4();
377

            
378
2
        let (from_num, from_denom) = parse_amount_to_rational("25584").unwrap();
379
2
        let (to_num, to_denom) = parse_amount_to_rational("153.81").unwrap();
380

            
381
2
        let price = build_conversion_price(
382
2
            &ConversionSide {
383
2
                split_id: from_id,
384
2
                commodity: from_commodity,
385
2
                num: from_num,
386
2
                denom: from_denom,
387
2
            },
388
2
            &ConversionSide {
389
2
                split_id: to_id,
390
2
                commodity: to_commodity,
391
2
                num: to_num,
392
2
                denom: to_denom,
393
2
            },
394
        );
395

            
396
2
        let from_val = Rational64::new(-from_num, from_denom);
397
2
        let to_val = Rational64::new(to_num, to_denom);
398
2
        let conv_rate = Rational64::new(price.value_num, price.value_denom);
399

            
400
        // commodity_split is to_split, so to_val * conv_rate must cancel from_val
401
2
        assert_eq!(from_val + to_val * conv_rate, Rational64::from_integer(0));
402
2
    }
403

            
404
    #[test]
405
2
    fn test_conversion_price_integer_amounts() {
406
2
        let from_id = Uuid::new_v4();
407
2
        let to_id = Uuid::new_v4();
408
2
        let from_commodity = Uuid::new_v4();
409
2
        let to_commodity = Uuid::new_v4();
410

            
411
2
        let (from_num, from_denom) = parse_amount_to_rational("1000").unwrap();
412
2
        let (to_num, to_denom) = parse_amount_to_rational("7").unwrap();
413

            
414
2
        let price = build_conversion_price(
415
2
            &ConversionSide {
416
2
                split_id: from_id,
417
2
                commodity: from_commodity,
418
2
                num: from_num,
419
2
                denom: from_denom,
420
2
            },
421
2
            &ConversionSide {
422
2
                split_id: to_id,
423
2
                commodity: to_commodity,
424
2
                num: to_num,
425
2
                denom: to_denom,
426
2
            },
427
        );
428

            
429
2
        let from_val = Rational64::new(-from_num, from_denom);
430
2
        let to_val = Rational64::new(to_num, to_denom);
431
2
        let conv_rate = Rational64::new(price.value_num, price.value_denom);
432

            
433
2
        assert_eq!(from_val + to_val * conv_rate, Rational64::from_integer(0));
434
2
    }
435

            
436
    #[test]
437
2
    fn test_conversion_price_both_fractional() {
438
2
        let from_id = Uuid::new_v4();
439
2
        let to_id = Uuid::new_v4();
440
2
        let from_commodity = Uuid::new_v4();
441
2
        let to_commodity = Uuid::new_v4();
442

            
443
2
        let (from_num, from_denom) = parse_amount_to_rational("99.50").unwrap();
444
2
        let (to_num, to_denom) = parse_amount_to_rational("85.23").unwrap();
445

            
446
2
        let price = build_conversion_price(
447
2
            &ConversionSide {
448
2
                split_id: from_id,
449
2
                commodity: from_commodity,
450
2
                num: from_num,
451
2
                denom: from_denom,
452
2
            },
453
2
            &ConversionSide {
454
2
                split_id: to_id,
455
2
                commodity: to_commodity,
456
2
                num: to_num,
457
2
                denom: to_denom,
458
2
            },
459
        );
460

            
461
2
        let from_val = Rational64::new(-from_num, from_denom);
462
2
        let to_val = Rational64::new(to_num, to_denom);
463
2
        let conv_rate = Rational64::new(price.value_num, price.value_denom);
464

            
465
2
        assert_eq!(from_val + to_val * conv_rate, Rational64::from_integer(0));
466
2
    }
467
}