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]
98pub fn parse_transaction_date(date_str: Option<&str>) -> NaiveDateTime {
99 date_str
100 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
101 .map_or_else(|| Local::now().naive_utc(), |dt| dt.naive_utc())
102}
103
104pub fn parse_uuid(
106 uuid_str: &str,
107 field_name: &str,
108) -> Result<Uuid, (StatusCode, Json<serde_json::Value>)> {
109 Uuid::parse_str(uuid_str).map_err(|_| {
110 let error_response = serde_json::json!({
111 "status": "fail",
112 "message": format!("Invalid {}: {}", field_name, uuid_str),
113 });
114 (StatusCode::BAD_REQUEST, Json(error_response))
115 })
116}
117
118pub fn validate_basic_amount(
120 amount_str: &str,
121) -> Result<f64, (StatusCode, Json<serde_json::Value>)> {
122 let amount_value = amount_str.parse::<f64>().map_err(|_| {
123 let error_response = serde_json::json!({
124 "status": "fail",
125 "message": format!("Invalid amount: {}", amount_str),
126 });
127 (StatusCode::BAD_REQUEST, Json(error_response))
128 })?;
129
130 if amount_value <= 0.0 {
131 let error_response = serde_json::json!({
132 "status": "fail",
133 "message": t!("Split amount must be positive"),
134 });
135 return Err((StatusCode::BAD_REQUEST, Json(error_response)));
136 }
137
138 Ok(amount_value)
139}
140
141pub fn parse_amount_to_rational(
145 amount_str: &str,
146) -> Result<(i64, i64), (StatusCode, Json<serde_json::Value>)> {
147 validate_basic_amount(amount_str)?;
148
149 let trimmed = amount_str.trim();
150 let (numer, denom) = if let Some(dot_pos) = trimmed.find('.') {
151 let decimals = trimmed.len() - dot_pos - 1;
152 let without_dot: String = trimmed.chars().filter(|c| *c != '.').collect();
153 let n = without_dot.parse::<i64>().map_err(|_| {
154 let error_response = serde_json::json!({
155 "status": "fail",
156 "message": format!("Cannot represent amount as rational: {}", amount_str),
157 });
158 (StatusCode::BAD_REQUEST, Json(error_response))
159 })?;
160 (n, 10_i64.pow(decimals as u32))
161 } else {
162 let n = trimmed.parse::<i64>().map_err(|_| {
163 let error_response = serde_json::json!({
164 "status": "fail",
165 "message": format!("Cannot represent amount as rational: {}", amount_str),
166 });
167 (StatusCode::BAD_REQUEST, Json(error_response))
168 })?;
169 (n, 1)
170 };
171
172 let r = Rational64::new(numer, denom);
173 Ok((*r.numer(), *r.denom()))
174}
175
176pub async fn process_split_data(
178 tx_id: Uuid,
179 split_data: SplitData,
180) -> Result<ProcessedSplit, (StatusCode, Json<serde_json::Value>)> {
181 validate_basic_amount(&split_data.amount)?;
182
183 let from_account_id = parse_uuid(&split_data.from_account, "from account ID")?;
184 let to_account_id = parse_uuid(&split_data.to_account, "to account ID")?;
185 let from_commodity = parse_uuid(&split_data.from_commodity, "from commodity ID")?;
186 let to_commodity = parse_uuid(&split_data.to_commodity, "to commodity ID")?;
187
188 let conversion = from_commodity != to_commodity;
190 if conversion {
191 validate_basic_amount(&split_data.amount_converted)?;
192 }
193
194 let (from_num, from_denom) = parse_amount_to_rational(&split_data.amount)?;
195
196 let from_split_id = Uuid::new_v4();
197 let to_split_id = Uuid::new_v4();
198
199 let (to_num, to_denom, price) = if conversion {
200 let (to_num, to_denom) = parse_amount_to_rational(&split_data.amount_converted)?;
201
202 let price = build_conversion_price(
203 from_split_id,
204 to_split_id,
205 from_commodity,
206 to_commodity,
207 from_num,
208 from_denom,
209 to_num,
210 to_denom,
211 );
212
213 (to_num, to_denom, Some(price))
214 } else {
215 (from_num, from_denom, None)
216 };
217
218 let from_split = finance::split::Split {
220 id: from_split_id,
221 tx_id,
222 account_id: from_account_id,
223 commodity_id: from_commodity,
224 value_num: -from_num,
225 value_denom: from_denom,
226 reconcile_state: None,
227 reconcile_date: None,
228 lot_id: None,
229 };
230
231 let to_split = finance::split::Split {
232 id: to_split_id,
233 tx_id,
234 account_id: to_account_id,
235 commodity_id: to_commodity,
236 value_num: to_num,
237 value_denom: to_denom,
238 reconcile_state: None,
239 reconcile_date: None,
240 lot_id: None,
241 };
242
243 Ok(ProcessedSplit {
244 from_split,
245 to_split,
246 price,
247 from_split_tags: split_data.from_tags,
248 to_split_tags: split_data.to_tags,
249 })
250}
251
252#[must_use]
254pub fn create_transaction_tags(note: Option<&str>) -> HashMap<String, FinanceEntity> {
255 let mut tags = HashMap::new();
256 if let Some(note_str) = note
257 && !note_str.trim().is_empty()
258 {
259 let tag = finance::tag::Tag {
260 id: Uuid::new_v4(),
261 tag_name: "note".to_string(),
262 tag_value: note_str.to_string(),
263 description: None,
264 };
265 tags.insert("note".to_string(), FinanceEntity::Tag(tag));
266 }
267 tags
268}
269
270#[must_use]
276pub fn build_conversion_price(
277 from_split_id: Uuid,
278 to_split_id: Uuid,
279 from_commodity: Uuid,
280 to_commodity: Uuid,
281 from_num: i64,
282 from_denom: i64,
283 to_num: i64,
284 to_denom: i64,
285) -> Price {
286 Price {
287 id: Uuid::new_v4(),
288 date: chrono::Utc::now(),
289 commodity_id: to_commodity,
290 currency_id: from_commodity,
291 commodity_split: Some(to_split_id),
292 currency_split: Some(from_split_id),
293 value_num: from_num * to_denom,
294 value_denom: from_denom * to_num,
295 }
296}
297
298pub fn validate_splits_not_empty<T>(
300 splits: &[T],
301) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
302 if splits.is_empty() {
303 let error_response = serde_json::json!({
304 "status": "fail",
305 "message": t!("At least one split is required for a transaction"),
306 });
307 return Err((StatusCode::BAD_REQUEST, Json(error_response)));
308 }
309 Ok(())
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use num_rational::Rational64;
316
317 #[test]
318 fn test_parse_amount_integer() {
319 let (num, denom) = parse_amount_to_rational("25584").unwrap();
320 assert_eq!(Rational64::new(num, denom), Rational64::from_integer(25584));
321 }
322
323 #[test]
324 fn test_parse_amount_fractional() {
325 let (num, denom) = parse_amount_to_rational("153.81").unwrap();
326 let r = Rational64::new(num, denom);
327 assert_eq!(r, Rational64::new(15381, 100));
328 }
329
330 #[test]
331 fn test_conversion_price_jpy_usd() {
332 let from_id = Uuid::new_v4();
333 let to_id = Uuid::new_v4();
334 let from_commodity = Uuid::new_v4();
335 let to_commodity = Uuid::new_v4();
336
337 let (from_num, from_denom) = parse_amount_to_rational("25584").unwrap();
338 let (to_num, to_denom) = parse_amount_to_rational("153.81").unwrap();
339
340 let price = build_conversion_price(
341 from_id,
342 to_id,
343 from_commodity,
344 to_commodity,
345 from_num,
346 from_denom,
347 to_num,
348 to_denom,
349 );
350
351 let from_val = Rational64::new(-from_num, from_denom);
352 let to_val = Rational64::new(to_num, to_denom);
353 let conv_rate = Rational64::new(price.value_num, price.value_denom);
354
355 assert_eq!(from_val + to_val * conv_rate, Rational64::from_integer(0));
357 }
358
359 #[test]
360 fn test_conversion_price_integer_amounts() {
361 let from_id = Uuid::new_v4();
362 let to_id = Uuid::new_v4();
363 let from_commodity = Uuid::new_v4();
364 let to_commodity = Uuid::new_v4();
365
366 let (from_num, from_denom) = parse_amount_to_rational("1000").unwrap();
367 let (to_num, to_denom) = parse_amount_to_rational("7").unwrap();
368
369 let price = build_conversion_price(
370 from_id,
371 to_id,
372 from_commodity,
373 to_commodity,
374 from_num,
375 from_denom,
376 to_num,
377 to_denom,
378 );
379
380 let from_val = Rational64::new(-from_num, from_denom);
381 let to_val = Rational64::new(to_num, to_denom);
382 let conv_rate = Rational64::new(price.value_num, price.value_denom);
383
384 assert_eq!(from_val + to_val * conv_rate, Rational64::from_integer(0));
385 }
386
387 #[test]
388 fn test_conversion_price_both_fractional() {
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("99.50").unwrap();
395 let (to_num, to_denom) = parse_amount_to_rational("85.23").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));
413 }
414}