1use 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#[derive(Deserialize, Debug)]
14pub struct SplitData {
15 pub split_id: Option<String>, 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
33pub 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
42pub 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
68pub 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#[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
112fn 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
129pub 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
143pub 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
166pub 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
201pub 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 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 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#[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#[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
323pub 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 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 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 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}