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