Skip to main content

web/pages/transaction/
util.rs

1//web/src/pages/transaction/util.rs - Shared utility functions for transaction operations
2
3use axum::{Json, http::StatusCode};
4use chrono::{Local, NaiveDateTime};
5use finance::price::Price;
6use num_rational::Rational64;
7use serde::Deserialize;
8use server::command::{CmdResult, FinanceEntity, commodity::GetCommodity};
9use sqlx::types::Uuid;
10
11/// Common `SplitData` structure used by both create and edit
12#[derive(Deserialize, Debug)]
13pub 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)]
25pub struct TagData {
26    pub name: String,
27    pub value: String,
28    pub description: Option<String>,
29}
30
31/// Result of processing a single split
32pub 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
41pub 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
67pub 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]
102pub fn parse_transaction_date(date_str: Option<&str>) -> NaiveDateTime {
103    date_str
104        .map(str::trim)
105        .filter(|s| !s.is_empty())
106        .and_then(parse_form_datetime)
107        .unwrap_or_else(|| Local::now().naive_utc())
108}
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.
112fn parse_form_datetime(s: &str) -> Option<NaiveDateTime> {
113    use chrono::{NaiveDate, NaiveDateTime};
114    for fmt in ["%Y-%m-%dT%H:%M", "%Y-%m-%dT%H:%M:%S"] {
115        if let Ok(dt) = NaiveDateTime::parse_from_str(s, fmt) {
116            return Some(dt);
117        }
118    }
119    if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
120        return d.and_hms_opt(0, 0, 0);
121    }
122    chrono::DateTime::parse_from_rfc3339(s)
123        .ok()
124        .map(|dt| dt.naive_utc())
125}
126
127/// Parse and validate UUID with custom error message
128pub fn parse_uuid(
129    uuid_str: &str,
130    field_name: &str,
131) -> Result<Uuid, (StatusCode, Json<serde_json::Value>)> {
132    Uuid::parse_str(uuid_str).map_err(|_| {
133        let error_response = serde_json::json!({
134            "status": "fail",
135            "message": format!("Invalid {}: {}", field_name, uuid_str),
136        });
137        (StatusCode::BAD_REQUEST, Json(error_response))
138    })
139}
140
141/// Validate basic amount parsing and positivity (without precision checking)
142pub fn validate_basic_amount(
143    amount_str: &str,
144) -> Result<f64, (StatusCode, Json<serde_json::Value>)> {
145    let amount_value = amount_str.parse::<f64>().map_err(|_| {
146        let error_response = serde_json::json!({
147            "status": "fail",
148            "message": format!("Invalid amount: {}", amount_str),
149        });
150        (StatusCode::BAD_REQUEST, Json(error_response))
151    })?;
152
153    if amount_value <= 0.0 {
154        let error_response = serde_json::json!({
155            "status": "fail",
156            "message": t!("Split amount must be positive"),
157        });
158        return Err((StatusCode::BAD_REQUEST, Json(error_response)));
159    }
160
161    Ok(amount_value)
162}
163
164/// Parse a decimal string directly into an exact rational (numerator, denominator).
165///
166/// `"153.81"` becomes `(15381, 100)` — no floating-point intermediary.
167pub fn parse_amount_to_rational(
168    amount_str: &str,
169) -> Result<(i64, i64), (StatusCode, Json<serde_json::Value>)> {
170    validate_basic_amount(amount_str)?;
171
172    let trimmed = amount_str.trim();
173    let (numer, denom) = if let Some(dot_pos) = trimmed.find('.') {
174        let decimals = trimmed.len() - dot_pos - 1;
175        let without_dot: String = trimmed.chars().filter(|c| *c != '.').collect();
176        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        (n, 10_i64.pow(decimals as u32))
184    } else {
185        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        (n, 1)
193    };
194
195    let r = Rational64::new(numer, denom);
196    Ok((*r.numer(), *r.denom()))
197}
198
199/// Process a single split data into finance entities
200pub async fn process_split_data(
201    tx_id: Uuid,
202    split_data: SplitData,
203) -> Result<ProcessedSplit, (StatusCode, Json<serde_json::Value>)> {
204    validate_basic_amount(&split_data.amount)?;
205
206    let from_account_id = parse_uuid(&split_data.from_account, "from account ID")?;
207    let to_account_id = parse_uuid(&split_data.to_account, "to account ID")?;
208    let from_commodity = parse_uuid(&split_data.from_commodity, "from commodity ID")?;
209    let to_commodity = parse_uuid(&split_data.to_commodity, "to commodity ID")?;
210
211    // Only validate amount_converted if currency conversion is needed
212    let conversion = from_commodity != to_commodity;
213    if conversion {
214        validate_basic_amount(&split_data.amount_converted)?;
215    }
216
217    let (from_num, from_denom) = parse_amount_to_rational(&split_data.amount)?;
218
219    let from_split_id = Uuid::new_v4();
220    let to_split_id = Uuid::new_v4();
221
222    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        (from_num, from_denom, None)
243    };
244
245    // Create split entities
246    let from_split = finance::split::Split {
247        id: from_split_id,
248        tx_id,
249        account_id: from_account_id,
250        commodity_id: from_commodity,
251        value_num: -from_num,
252        value_denom: from_denom,
253        reconcile_state: None,
254        reconcile_date: None,
255        lot_id: None,
256    };
257
258    let to_split = finance::split::Split {
259        id: to_split_id,
260        tx_id,
261        account_id: to_account_id,
262        commodity_id: to_commodity,
263        value_num: to_num,
264        value_denom: to_denom,
265        reconcile_state: None,
266        reconcile_date: None,
267        lot_id: None,
268    };
269
270    Ok(ProcessedSplit {
271        from_split,
272        to_split,
273        price,
274        from_split_tags: split_data.from_tags,
275        to_split_tags: split_data.to_tags,
276    })
277}
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.
286pub struct ConversionSide {
287    pub split_id: Uuid,
288    pub commodity: Uuid,
289    pub num: i64,
290    pub denom: i64,
291}
292
293#[must_use]
294pub fn build_conversion_price(from: &ConversionSide, to: &ConversionSide) -> Price {
295    Price {
296        id: Uuid::new_v4(),
297        date: chrono::Utc::now(),
298        commodity_id: to.commodity,
299        currency_id: from.commodity,
300        commodity_split: Some(to.split_id),
301        currency_split: Some(from.split_id),
302        value_num: from.num * to.denom,
303        value_denom: from.denom * to.num,
304    }
305}
306
307/// Validate that splits are not empty
308pub fn validate_splits_not_empty<T>(
309    splits: &[T],
310) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
311    if splits.is_empty() {
312        let error_response = serde_json::json!({
313            "status": "fail",
314            "message": t!("At least one split is required for a transaction"),
315        });
316        return Err((StatusCode::BAD_REQUEST, Json(error_response)));
317    }
318    Ok(())
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use num_rational::Rational64;
325
326    #[test]
327    fn test_parse_amount_integer() {
328        let (num, denom) = parse_amount_to_rational("25584").unwrap();
329        assert_eq!(Rational64::new(num, denom), Rational64::from_integer(25584));
330    }
331
332    #[test]
333    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        let dt = parse_transaction_date(Some("2026-06-15T09:30"));
337        assert_eq!(dt.format("%Y-%m-%dT%H:%M").to_string(), "2026-06-15T09:30");
338        let with_secs = parse_transaction_date(Some("2026-06-15T09:30:45"));
339        assert_eq!(with_secs.format("%H:%M:%S").to_string(), "09:30:45");
340    }
341
342    #[test]
343    fn parse_transaction_date_accepts_bare_date_and_rfc3339() {
344        let bare = parse_transaction_date(Some("2026-06-15"));
345        assert_eq!(
346            bare.format("%Y-%m-%dT%H:%M").to_string(),
347            "2026-06-15T00:00"
348        );
349        let rfc = parse_transaction_date(Some("2026-06-15T09:30:00+00:00"));
350        assert_eq!(rfc.format("%Y-%m-%dT%H:%M").to_string(), "2026-06-15T09:30");
351    }
352
353    #[test]
354    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        let a = parse_transaction_date(None);
358        let b = parse_transaction_date(Some("   "));
359        let now = Local::now().naive_utc();
360        assert!((now - a).num_seconds().abs() < 5);
361        assert!((now - b).num_seconds().abs() < 5);
362    }
363
364    #[test]
365    fn test_parse_amount_fractional() {
366        let (num, denom) = parse_amount_to_rational("153.81").unwrap();
367        let r = Rational64::new(num, denom);
368        assert_eq!(r, Rational64::new(15381, 100));
369    }
370
371    #[test]
372    fn test_conversion_price_jpy_usd() {
373        let from_id = Uuid::new_v4();
374        let to_id = Uuid::new_v4();
375        let from_commodity = Uuid::new_v4();
376        let to_commodity = Uuid::new_v4();
377
378        let (from_num, from_denom) = parse_amount_to_rational("25584").unwrap();
379        let (to_num, to_denom) = parse_amount_to_rational("153.81").unwrap();
380
381        let price = build_conversion_price(
382            &ConversionSide {
383                split_id: from_id,
384                commodity: from_commodity,
385                num: from_num,
386                denom: from_denom,
387            },
388            &ConversionSide {
389                split_id: to_id,
390                commodity: to_commodity,
391                num: to_num,
392                denom: to_denom,
393            },
394        );
395
396        let from_val = Rational64::new(-from_num, from_denom);
397        let to_val = Rational64::new(to_num, to_denom);
398        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        assert_eq!(from_val + to_val * conv_rate, Rational64::from_integer(0));
402    }
403
404    #[test]
405    fn test_conversion_price_integer_amounts() {
406        let from_id = Uuid::new_v4();
407        let to_id = Uuid::new_v4();
408        let from_commodity = Uuid::new_v4();
409        let to_commodity = Uuid::new_v4();
410
411        let (from_num, from_denom) = parse_amount_to_rational("1000").unwrap();
412        let (to_num, to_denom) = parse_amount_to_rational("7").unwrap();
413
414        let price = build_conversion_price(
415            &ConversionSide {
416                split_id: from_id,
417                commodity: from_commodity,
418                num: from_num,
419                denom: from_denom,
420            },
421            &ConversionSide {
422                split_id: to_id,
423                commodity: to_commodity,
424                num: to_num,
425                denom: to_denom,
426            },
427        );
428
429        let from_val = Rational64::new(-from_num, from_denom);
430        let to_val = Rational64::new(to_num, to_denom);
431        let conv_rate = Rational64::new(price.value_num, price.value_denom);
432
433        assert_eq!(from_val + to_val * conv_rate, Rational64::from_integer(0));
434    }
435
436    #[test]
437    fn test_conversion_price_both_fractional() {
438        let from_id = Uuid::new_v4();
439        let to_id = Uuid::new_v4();
440        let from_commodity = Uuid::new_v4();
441        let to_commodity = Uuid::new_v4();
442
443        let (from_num, from_denom) = parse_amount_to_rational("99.50").unwrap();
444        let (to_num, to_denom) = parse_amount_to_rational("85.23").unwrap();
445
446        let price = build_conversion_price(
447            &ConversionSide {
448                split_id: from_id,
449                commodity: from_commodity,
450                num: from_num,
451                denom: from_denom,
452            },
453            &ConversionSide {
454                split_id: to_id,
455                commodity: to_commodity,
456                num: to_num,
457                denom: to_denom,
458            },
459        );
460
461        let from_val = Rational64::new(-from_num, from_denom);
462        let to_val = Rational64::new(to_num, to_denom);
463        let conv_rate = Rational64::new(price.value_num, price.value_denom);
464
465        assert_eq!(from_val + to_val * conv_rate, Rational64::from_integer(0));
466    }
467}