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;
10
11#[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
31pub 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
40pub 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
66pub 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#[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
110fn 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
127pub 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
141pub 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
164pub 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
199pub 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 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 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
279pub 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
307pub 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 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 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 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}