1use chrono::{DateTime, Utc};
9use finance::split::Split;
10use finance::tag::Tag;
11use nomiscript::{Expr, Fraction, Reader};
12use scripting::runtime::{
13 alloc_entity_via_export, alloc_pair_chain, alloc_string_ref, read_string_arg,
14};
15#[cfg(test)]
16use server::command::PaginationInfo;
17use server::command::transaction::{
18 CreateTransaction, DeleteTransaction, GetTransaction, GetTransactionTag, ListTransactions,
19 SetTransactionTag, UpdateTransaction,
20};
21use server::command::{CmdError, CmdResult, FinanceEntity};
22use uuid::Uuid;
23use wasmtime::{AnyRef, ArrayRef, Caller, Linker, Rooted, StructRef, Val};
24
25use crate::session::SessionData;
26
27pub const REGISTERED_COMMANDS: &[&str] = &[
28 "create-transaction",
29 "list-transactions",
30 "get-transaction",
31 "update-transaction",
32 "delete-transaction",
33 "set-transaction-tag",
34 "get-transaction-tag",
35];
36
37pub fn register(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
38 register_readonly(linker)?;
39 register_mutators(linker)?;
40 Ok(())
41}
42
43pub fn register_readonly(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
44 linker.func_wrap_async(
45 "nomi",
46 "transaction_list_transactions",
47 |mut caller: Caller<'_, SessionData>,
48 ()|
49 -> Box<
50 dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
51 > {
52 Box::new(async move {
53 let user_id = caller.data().ctx().user_id;
54 let result = ListTransactions::new().user_id(user_id).run().await;
55 let entries = list_transaction_entries("list-transactions", result)?;
56 alloc_transaction_chain(&mut caller, entries).await
57 })
58 },
59 )?;
60 linker.func_wrap_async(
61 "nomi",
62 "transaction_get_transaction",
63 |mut caller: Caller<'_, SessionData>,
64 (id_arg,): (Option<Rooted<ArrayRef>>,)|
65 -> Box<
66 dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
67 > {
68 Box::new(async move {
69 let user_id = caller.data().ctx().user_id;
70 let id = read_string_arg(&mut caller, id_arg)?;
71 run_get_transaction(&mut caller, user_id, id).await
72 })
73 },
74 )?;
75 linker.func_wrap_async(
76 "nomi",
77 "transaction_get_transaction_tag",
78 |mut caller: Caller<'_, SessionData>,
79 (id_arg, name_arg): (Option<Rooted<ArrayRef>>, Option<Rooted<ArrayRef>>)|
80 -> Box<
81 dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<ArrayRef>>>> + Send,
82 > {
83 Box::new(async move {
84 let user_id = caller.data().ctx().user_id;
85 let id = read_string_arg(&mut caller, id_arg)?;
86 let name = read_string_arg(&mut caller, name_arg)?;
87 let value = run_get_transaction_tag(user_id, id, name).await?;
88 Ok(Some(alloc_string_ref(&mut caller, value.as_bytes())?))
89 })
90 },
91 )?;
92 Ok(())
93}
94
95pub fn register_mutators(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
96 linker.func_wrap_async(
97 "nomi",
98 "transaction_delete_transaction",
99 |mut caller: Caller<'_, SessionData>,
100 (id_arg,): (Option<Rooted<ArrayRef>>,)|
101 -> Box<dyn std::future::Future<Output = wasmtime::Result<i32>> + Send> {
102 Box::new(async move {
103 let user_id = caller.data().ctx().user_id;
104 let id = read_string_arg(&mut caller, id_arg)?;
105 run_delete_transaction(user_id, id).await
106 })
107 },
108 )?;
109 linker.func_wrap_async(
110 "nomi",
111 "transaction_create_transaction",
112 |mut caller: Caller<'_, SessionData>,
113 (payload_arg,): (Option<Rooted<ArrayRef>>,)|
114 -> Box<
115 dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<ArrayRef>>>> + Send,
116 > {
117 Box::new(async move {
118 let user_id = caller.data().ctx().user_id;
119 let payload = read_string_arg(&mut caller, payload_arg)?;
120 let id = run_create_transaction(user_id, payload).await?;
121 Ok(Some(alloc_string_ref(&mut caller, id.as_bytes())?))
122 })
123 },
124 )?;
125 linker.func_wrap_async(
126 "nomi",
127 "transaction_update_transaction",
128 |mut caller: Caller<'_, SessionData>,
129 (payload_arg,): (Option<Rooted<ArrayRef>>,)|
130 -> Box<
131 dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<ArrayRef>>>> + Send,
132 > {
133 Box::new(async move {
134 let user_id = caller.data().ctx().user_id;
135 let payload = read_string_arg(&mut caller, payload_arg)?;
136 let id = run_update_transaction(user_id, payload).await?;
137 Ok(Some(alloc_string_ref(&mut caller, id.as_bytes())?))
138 })
139 },
140 )?;
141 linker.func_wrap_async(
142 "nomi",
143 "transaction_set_transaction_tag",
144 |mut caller: Caller<'_, SessionData>,
145 (id_arg, name_arg, value_arg): super::StringArgTriple|
146 -> Box<dyn std::future::Future<Output = wasmtime::Result<i32>> + Send> {
147 Box::new(async move {
148 let user_id = caller.data().ctx().user_id;
149 let id = read_string_arg(&mut caller, id_arg)?;
150 let name = read_string_arg(&mut caller, name_arg)?;
151 let value = read_string_arg(&mut caller, value_arg)?;
152 run_set_transaction_tag(user_id, id, name, value).await
153 })
154 },
155 )?;
156 Ok(())
157}
158
159async fn run_set_transaction_tag(
162 user_id: Uuid,
163 id_arg: Option<String>,
164 name_arg: Option<String>,
165 value_arg: Option<String>,
166) -> wasmtime::Result<i32> {
167 let raw = id_arg.filter(|s| !s.is_empty()).ok_or_else(|| {
168 wasmtime::Error::msg("set-transaction-tag: missing or empty :transaction-id arg")
169 })?;
170 let transaction_id = Uuid::parse_str(&raw).map_err(|err| {
171 wasmtime::Error::msg(format!("set-transaction-tag: invalid uuid '{raw}': {err}"))
172 })?;
173 let tag_name = name_arg.filter(|s| !s.is_empty()).ok_or_else(|| {
174 wasmtime::Error::msg("set-transaction-tag: missing or empty :tag-name arg")
175 })?;
176 let tag_value = value_arg
177 .ok_or_else(|| wasmtime::Error::msg("set-transaction-tag: missing :tag-value arg"))?;
178 SetTransactionTag::new()
179 .user_id(user_id)
180 .transaction_id(transaction_id)
181 .tag_name(tag_name)
182 .tag_value(tag_value)
183 .run()
184 .await
185 .map(|_| 1)
186 .map_err(|err| wasmtime::Error::msg(format!("set-transaction-tag: {err}")))
187}
188
189async fn run_get_transaction_tag(
192 user_id: Uuid,
193 id_arg: Option<String>,
194 name_arg: Option<String>,
195) -> wasmtime::Result<String> {
196 let raw = id_arg.filter(|s| !s.is_empty()).ok_or_else(|| {
197 wasmtime::Error::msg("get-transaction-tag: missing or empty :transaction-id arg")
198 })?;
199 let transaction_id = Uuid::parse_str(&raw).map_err(|err| {
200 wasmtime::Error::msg(format!("get-transaction-tag: invalid uuid '{raw}': {err}"))
201 })?;
202 let tag_name = name_arg.filter(|s| !s.is_empty()).ok_or_else(|| {
203 wasmtime::Error::msg("get-transaction-tag: missing or empty :tag-name arg")
204 })?;
205 match GetTransactionTag::new()
206 .user_id(user_id)
207 .transaction_id(transaction_id)
208 .tag_name(tag_name)
209 .run()
210 .await
211 {
212 Ok(Some(CmdResult::String(s))) => Ok(s),
213 Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
214 "get-transaction-tag: expected String, got {other:?}"
215 ))),
216 Ok(None) => Ok(String::new()),
217 Err(err) => Err(wasmtime::Error::msg(format!("get-transaction-tag: {err}"))),
218 }
219}
220
221async fn run_update_transaction(
228 user_id: Uuid,
229 payload_arg: Option<String>,
230) -> wasmtime::Result<String> {
231 let payload = payload_arg
232 .filter(|s| !s.is_empty())
233 .ok_or_else(|| wasmtime::Error::msg("update-transaction: missing or empty :payload arg"))?;
234 let input = parse_update_transaction_payload(&payload)
235 .map_err(|err| wasmtime::Error::msg(format!("update-transaction: {err}")))?;
236 let mut runner = UpdateTransaction::new()
237 .user_id(user_id)
238 .transaction_id(input.transaction_id);
239 if let Some(post_date) = input.post_date {
240 runner = runner.post_date(post_date);
241 }
242 if let Some(enter_date) = input.enter_date {
243 runner = runner.enter_date(enter_date);
244 }
245 if let Some(note) = input.note {
246 runner = runner.note(note);
247 }
248 if let Some(splits) = input.splits {
249 let entities: Vec<FinanceEntity> = splits
250 .into_iter()
251 .map(|mut s| {
252 s.tx_id = input.transaction_id;
253 FinanceEntity::Split(s)
254 })
255 .collect();
256 runner = runner.splits(entities);
257 }
258 match runner.run().await {
259 Ok(Some(CmdResult::Entity(FinanceEntity::Transaction(tx)))) => Ok(tx.id.to_string()),
260 Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
261 "update-transaction: expected Transaction entity, got {other:?}"
262 ))),
263 Ok(None) => Err(wasmtime::Error::msg(
264 "update-transaction: command returned no entity",
265 )),
266 Err(err) => Err(wasmtime::Error::msg(format!("update-transaction: {err}"))),
267 }
268}
269
270#[derive(Debug)]
271struct UpdateTransactionInput {
272 transaction_id: Uuid,
273 post_date: Option<DateTime<Utc>>,
274 enter_date: Option<DateTime<Utc>>,
275 note: Option<String>,
276 splits: Option<Vec<Split>>,
277}
278
279fn parse_update_transaction_payload(src: &str) -> Result<UpdateTransactionInput, String> {
280 let program = Reader::parse(src).map_err(|err| format!("payload parse: {err}"))?;
281 let first = program
282 .exprs
283 .into_iter()
284 .next()
285 .ok_or_else(|| "payload empty".to_string())?;
286 let plist = expect_plist(first, "payload")?;
287 let tx_raw = take_plist_string(&plist, "transaction-id")?
288 .ok_or_else(|| "payload: missing :transaction-id".to_string())?;
289 let transaction_id = Uuid::parse_str(&tx_raw)
290 .map_err(|err| format!("payload: invalid :transaction-id '{tx_raw}': {err}"))?;
291 let post_date = match take_plist_string(&plist, "post-date")? {
292 Some(raw) => Some(
293 DateTime::parse_from_rfc3339(&raw)
294 .map(|d| d.with_timezone(&Utc))
295 .map_err(|err| format!("payload: invalid :post-date '{raw}': {err}"))?,
296 ),
297 None => None,
298 };
299 let enter_date = match take_plist_string(&plist, "enter-date")? {
300 Some(raw) => Some(
301 DateTime::parse_from_rfc3339(&raw)
302 .map(|d| d.with_timezone(&Utc))
303 .map_err(|err| format!("payload: invalid :enter-date '{raw}': {err}"))?,
304 ),
305 None => None,
306 };
307 let note = take_plist_string(&plist, "note")?;
308 let splits = match take_plist_list(&plist, "splits")? {
309 Some(list) => {
310 if list.len() < 2 {
311 return Err("payload: :splits must have at least two entries".into());
312 }
313 Some(
314 list.into_iter()
315 .enumerate()
316 .map(|(idx, e)| parse_split_plist(e, idx))
317 .collect::<Result<Vec<_>, _>>()?,
318 )
319 }
320 None => None,
321 };
322 Ok(UpdateTransactionInput {
323 transaction_id,
324 post_date,
325 enter_date,
326 note,
327 splits,
328 })
329}
330
331#[derive(Debug)]
339struct CreateTransactionInput {
340 id: Uuid,
341 post_date: DateTime<Utc>,
342 enter_date: DateTime<Utc>,
343 note: Option<String>,
344 splits: Vec<Split>,
345}
346
347async fn run_create_transaction(
348 user_id: Uuid,
349 payload_arg: Option<String>,
350) -> wasmtime::Result<String> {
351 let payload = payload_arg
352 .filter(|s| !s.is_empty())
353 .ok_or_else(|| wasmtime::Error::msg("create-transaction: missing or empty :payload arg"))?;
354 let input = parse_create_transaction_payload(&payload)
355 .map_err(|err| wasmtime::Error::msg(format!("create-transaction: {err}")))?;
356 let mut splits: Vec<FinanceEntity> = input
357 .splits
358 .into_iter()
359 .map(|mut s| {
360 s.tx_id = input.id;
361 FinanceEntity::Split(s)
362 })
363 .collect();
364 let mut runner = CreateTransaction::new()
365 .user_id(user_id)
366 .id(input.id)
367 .post_date(input.post_date)
368 .enter_date(input.enter_date)
369 .splits(std::mem::take(&mut splits));
370 if let Some(note) = input.note {
371 runner = runner.note(note);
372 }
373 match runner.run().await {
374 Ok(Some(CmdResult::Entity(FinanceEntity::Transaction(tx)))) => Ok(tx.id.to_string()),
375 Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
376 "create-transaction: expected Transaction entity, got {other:?}"
377 ))),
378 Ok(None) => Err(wasmtime::Error::msg(
379 "create-transaction: command returned no entity",
380 )),
381 Err(err) => Err(wasmtime::Error::msg(format!("create-transaction: {err}"))),
382 }
383}
384
385fn parse_create_transaction_payload(src: &str) -> Result<CreateTransactionInput, String> {
386 let program = Reader::parse(src).map_err(|err| format!("payload parse: {err}"))?;
387 let first = program
388 .exprs
389 .into_iter()
390 .next()
391 .ok_or_else(|| "payload empty".to_string())?;
392 let plist = expect_plist(first, "payload")?;
393 let post_date_raw = take_plist_string(&plist, "post-date")?
394 .ok_or_else(|| "payload: missing :post-date".to_string())?;
395 let post_date = DateTime::parse_from_rfc3339(&post_date_raw)
396 .map(|d| d.with_timezone(&Utc))
397 .map_err(|err| format!("payload: invalid :post-date '{post_date_raw}': {err}"))?;
398 let enter_date = match take_plist_string(&plist, "enter-date")? {
399 Some(raw) => DateTime::parse_from_rfc3339(&raw)
400 .map(|d| d.with_timezone(&Utc))
401 .map_err(|err| format!("payload: invalid :enter-date '{raw}': {err}"))?,
402 None => Utc::now(),
403 };
404 let id = match take_plist_string(&plist, "id")? {
405 Some(raw) => {
406 Uuid::parse_str(&raw).map_err(|err| format!("payload: invalid :id '{raw}': {err}"))?
407 }
408 None => Uuid::new_v4(),
409 };
410 let note = take_plist_string(&plist, "note")?;
411 let splits_list =
412 take_plist_list(&plist, "splits")?.ok_or_else(|| "payload: missing :splits".to_string())?;
413 if splits_list.len() < 2 {
414 return Err("payload: :splits must have at least two entries".into());
415 }
416 let splits = splits_list
417 .into_iter()
418 .enumerate()
419 .map(|(idx, expr)| parse_split_plist(expr, idx))
420 .collect::<Result<Vec<_>, _>>()?;
421 Ok(CreateTransactionInput {
422 id,
423 post_date,
424 enter_date,
425 note,
426 splits,
427 })
428}
429
430fn parse_split_plist(expr: Expr, idx: usize) -> Result<Split, String> {
431 let plist = expect_plist(expr, &format!("split[{idx}]"))?;
432 let account_raw = take_plist_string(&plist, "account-id")?
433 .ok_or_else(|| format!("split[{idx}]: missing :account-id"))?;
434 let account_id = Uuid::parse_str(&account_raw)
435 .map_err(|err| format!("split[{idx}]: invalid :account-id '{account_raw}': {err}"))?;
436 let commodity_raw = take_plist_string(&plist, "commodity-id")?
437 .ok_or_else(|| format!("split[{idx}]: missing :commodity-id"))?;
438 let commodity_id = Uuid::parse_str(&commodity_raw)
439 .map_err(|err| format!("split[{idx}]: invalid :commodity-id '{commodity_raw}': {err}"))?;
440 let value = take_plist_number(&plist, "value")?
441 .ok_or_else(|| format!("split[{idx}]: missing :value"))?;
442 Ok(Split {
443 id: Uuid::new_v4(),
444 tx_id: Uuid::nil(),
445 account_id,
446 commodity_id,
447 reconcile_state: None,
448 reconcile_date: None,
449 value_num: *value.numer(),
450 value_denom: *value.denom(),
451 lot_id: None,
452 })
453}
454
455fn expect_plist(expr: Expr, context: &str) -> Result<Vec<(String, Expr)>, String> {
460 let items = match expr {
461 Expr::List(items) => items,
462 other => return Err(format!("{context}: expected plist, got {other:?}")),
463 };
464 if !items.len().is_multiple_of(2) {
465 return Err(format!(
466 "{context}: plist has odd number of elements ({})",
467 items.len()
468 ));
469 }
470 let mut out = Vec::with_capacity(items.len() / 2);
471 let mut iter = items.into_iter();
472 while let Some(key) = iter.next() {
473 let key_name = match key {
474 Expr::Keyword(name) => name,
475 other => return Err(format!("{context}: expected :keyword, got {other:?}")),
476 };
477 let value = iter
478 .next()
479 .ok_or_else(|| format!("{context}: dangling :{key_name} without value"))?;
480 out.push((key_name, value));
481 }
482 Ok(out)
483}
484
485fn take_plist_string(plist: &[(String, Expr)], key: &str) -> Result<Option<String>, String> {
486 let upper = key.to_ascii_uppercase();
487 match plist.iter().find(|(k, _)| k.eq_ignore_ascii_case(&upper)) {
488 Some((_, Expr::String(s))) => Ok(Some(s.clone())),
489 Some((_, Expr::Nil)) => Ok(None),
490 Some((_, other)) => Err(format!(":{key} must be string, got {other:?}")),
491 None => Ok(None),
492 }
493}
494
495fn take_plist_number(plist: &[(String, Expr)], key: &str) -> Result<Option<Fraction>, String> {
496 let upper = key.to_ascii_uppercase();
497 match plist.iter().find(|(k, _)| k.eq_ignore_ascii_case(&upper)) {
498 Some((_, Expr::Number(n))) => Ok(Some(*n)),
499 Some((_, Expr::Nil)) => Ok(None),
500 Some((_, other)) => Err(format!(":{key} must be number, got {other:?}")),
501 None => Ok(None),
502 }
503}
504
505fn take_plist_list(plist: &[(String, Expr)], key: &str) -> Result<Option<Vec<Expr>>, String> {
506 let upper = key.to_ascii_uppercase();
507 match plist.iter().find(|(k, _)| k.eq_ignore_ascii_case(&upper)) {
508 Some((_, Expr::List(items))) => Ok(Some(items.clone())),
509 Some((_, Expr::Nil)) => Ok(None),
510 Some((_, other)) => Err(format!(":{key} must be list, got {other:?}")),
511 None => Ok(None),
512 }
513}
514
515async fn run_delete_transaction(user_id: Uuid, id_arg: Option<String>) -> wasmtime::Result<i32> {
520 let raw = id_arg.filter(|s| !s.is_empty()).ok_or_else(|| {
521 wasmtime::Error::msg("delete-transaction: missing or empty :transaction-id arg")
522 })?;
523 let transaction_id = Uuid::parse_str(&raw).map_err(|err| {
524 wasmtime::Error::msg(format!("delete-transaction: invalid uuid '{raw}': {err}"))
525 })?;
526 DeleteTransaction::new()
527 .user_id(user_id)
528 .transaction_id(transaction_id)
529 .run()
530 .await
531 .map(|_| 1)
532 .map_err(|err| wasmtime::Error::msg(format!("delete-transaction: {err}")))
533}
534
535async fn run_get_transaction(
536 caller: &mut Caller<'_, SessionData>,
537 user_id: Uuid,
538 id_arg: Option<String>,
539) -> wasmtime::Result<Option<Rooted<StructRef>>> {
540 let transaction_id = parse_transaction_id_arg(id_arg)?;
541 let result = GetTransaction::new()
542 .user_id(user_id)
543 .transaction_id(transaction_id)
544 .run()
545 .await;
546 let entries = list_transaction_entries("get-transaction", result)?;
547 match entries.into_iter().next() {
548 Some((id, note, post_date)) => Ok(Some(
549 alloc_transaction_entity(caller, &id, note.as_deref(), Some(&post_date)).await?,
550 )),
551 None => Ok(None),
552 }
553}
554
555fn parse_transaction_id_arg(id_arg: Option<String>) -> wasmtime::Result<Uuid> {
559 let raw = id_arg.filter(|s| !s.is_empty()).ok_or_else(|| {
560 wasmtime::Error::msg("get-transaction: missing or empty :transaction-id arg")
561 })?;
562 Uuid::parse_str(&raw).map_err(|err| {
563 wasmtime::Error::msg(format!("get-transaction: invalid uuid '{raw}': {err}"))
564 })
565}
566
567fn list_transaction_entries(
572 name: &str,
573 result: Result<Option<CmdResult>, CmdError>,
574) -> wasmtime::Result<Vec<(String, Option<String>, String)>> {
575 match result {
576 Ok(Some(CmdResult::TaggedEntities { entities, .. })) => Ok(entities
577 .into_iter()
578 .filter_map(|(entity, tags)| match entity {
579 FinanceEntity::Transaction(tx) => Some((
580 tx.id.to_string(),
581 tag_value(&tags, "note").map(str::to_string),
582 tx.post_date.to_rfc3339(),
583 )),
584 _ => None,
585 })
586 .collect()),
587 Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
588 "{name}: expected TaggedEntities, got {other:?}"
589 ))),
590 Ok(None) => Ok(Vec::new()),
591 Err(err) => Err(wasmtime::Error::msg(format!("{name}: {err}"))),
592 }
593}
594
595async fn alloc_transaction_entity(
596 caller: &mut Caller<'_, SessionData>,
597 id: &str,
598 note: Option<&str>,
599 post_date: Option<&str>,
600) -> wasmtime::Result<Rooted<StructRef>> {
601 let id_ref = alloc_string_ref(caller, id.as_bytes())?;
602 let note_ref = match note {
603 Some(s) => Some(alloc_string_ref(caller, s.as_bytes())?),
604 None => None,
605 };
606 let date_ref = match post_date {
607 Some(s) => Some(alloc_string_ref(caller, s.as_bytes())?),
608 None => None,
609 };
610 let args = [
611 Val::AnyRef(Some(id_ref.to_anyref())),
612 Val::AnyRef(note_ref.map(|r| r.to_anyref())),
613 Val::AnyRef(date_ref.map(|r| r.to_anyref())),
614 ];
615 alloc_entity_via_export(caller, "alloc_transaction", &args).await
616}
617
618async fn alloc_transaction_chain(
619 caller: &mut Caller<'_, SessionData>,
620 entries: Vec<(String, Option<String>, String)>,
621) -> wasmtime::Result<Option<Rooted<StructRef>>> {
622 let mut anyrefs: Vec<Rooted<AnyRef>> = Vec::with_capacity(entries.len());
623 for (id, note, post_date) in entries {
624 let entity_ref =
625 alloc_transaction_entity(caller, &id, note.as_deref(), Some(&post_date)).await?;
626 anyrefs.push(entity_ref.to_anyref());
627 }
628 alloc_pair_chain(caller, anyrefs).await
629}
630
631#[cfg(test)]
636fn format_tagged_transactions(
637 entities: &[(
638 FinanceEntity,
639 std::collections::HashMap<String, FinanceEntity>,
640 )],
641 pagination: Option<&PaginationInfo>,
642) -> String {
643 let mut out = String::from("(:transactions (");
644 for (idx, (entity, tags)) in entities.iter().enumerate() {
645 if idx > 0 {
646 out.push(' ');
647 }
648 match entity {
649 FinanceEntity::Transaction(tx) => {
650 out.push_str(&format!(
651 "(:id \"{}\" :post-date \"{}\" :enter-date \"{}\"",
652 tx.id,
653 tx.post_date.to_rfc3339(),
654 tx.enter_date.to_rfc3339()
655 ));
656 if let Some(note) = tag_value(tags, "note") {
657 out.push_str(&format!(" :note {}", quote_string(note)));
658 }
659 out.push(')');
660 }
661 other => {
662 out.push_str(&format!("(:error \"unexpected entity {other:?}\")"));
663 }
664 }
665 }
666 out.push_str(") :pagination ");
667 match pagination {
668 Some(p) => out.push_str(&format!(
669 "(:total {} :limit {} :offset {} :has-more {})",
670 p.total_count,
671 p.limit,
672 p.offset,
673 if p.has_more { "t" } else { "nil" }
674 )),
675 None => out.push_str("nil"),
676 }
677 out.push(')');
678 out
679}
680
681fn tag_value<'a>(
682 tags: &'a std::collections::HashMap<String, FinanceEntity>,
683 key: &str,
684) -> Option<&'a str> {
685 tags.get(key).and_then(|t| match t {
686 FinanceEntity::Tag(Tag { tag_value, .. }) => Some(tag_value.as_str()),
687 _ => None,
688 })
689}
690
691#[cfg(test)]
692fn quote_string(s: &str) -> String {
693 let mut q = String::with_capacity(s.len() + 2);
694 q.push('"');
695 for ch in s.chars() {
696 match ch {
697 '"' => q.push_str("\\\""),
698 '\\' => q.push_str("\\\\"),
699 other => q.push(other),
700 }
701 }
702 q.push('"');
703 q
704}
705
706#[cfg(test)]
707mod tests {
708 use super::*;
709 use chrono::TimeZone;
710 use finance::transaction::Transaction;
711 use std::collections::HashMap;
712 use uuid::Uuid;
713
714 fn tx_entity(id: Uuid) -> FinanceEntity {
715 let post = chrono::Utc.with_ymd_and_hms(2026, 5, 1, 12, 0, 0).unwrap();
716 let enter = chrono::Utc.with_ymd_and_hms(2026, 5, 2, 9, 30, 0).unwrap();
717 FinanceEntity::Transaction(Transaction {
718 id,
719 post_date: post,
720 enter_date: enter,
721 })
722 }
723
724 #[test]
725 fn format_empty_list_with_no_pagination() {
726 assert_eq!(
727 format_tagged_transactions(&[], None),
728 "(:transactions () :pagination nil)"
729 );
730 }
731
732 #[test]
733 fn format_single_transaction_with_note_and_pagination() {
734 let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
735 let mut tags = HashMap::new();
736 tags.insert(
737 "note".to_string(),
738 FinanceEntity::Tag(Tag {
739 id: Uuid::nil(),
740 tag_name: "note".into(),
741 tag_value: "groceries".into(),
742 description: None,
743 }),
744 );
745 let pagination = PaginationInfo {
746 total_count: 1,
747 limit: 20,
748 offset: 0,
749 has_more: false,
750 };
751 let out = format_tagged_transactions(&[(tx_entity(id), tags)], Some(&pagination));
752 assert!(out.contains(":id \"550e8400-e29b-41d4-a716-446655440000\""));
753 assert!(out.contains(":post-date \"2026-05-01T12:00:00+00:00\""));
754 assert!(out.contains(":enter-date \"2026-05-02T09:30:00+00:00\""));
755 assert!(out.contains(":note \"groceries\""));
756 assert!(out.contains(":pagination (:total 1 :limit 20 :offset 0 :has-more nil)"));
757 }
758
759 #[test]
760 fn format_transaction_without_tags_omits_note() {
761 let id = Uuid::nil();
762 let out = format_tagged_transactions(&[(tx_entity(id), HashMap::new())], None);
763 assert!(out.contains(":id \"00000000-0000-0000-0000-000000000000\""));
764 assert!(!out.contains(":note"));
765 assert!(out.ends_with(":pagination nil)"));
766 }
767
768 #[test]
769 fn parse_transaction_id_rejects_missing_arg() {
770 let err = parse_transaction_id_arg(None).unwrap_err();
771 assert!(err.to_string().contains("missing or empty"), "got: {err}");
772 }
773
774 #[test]
775 fn parse_transaction_id_rejects_invalid_uuid() {
776 let err = parse_transaction_id_arg(Some("not-a-uuid".into())).unwrap_err();
777 assert!(err.to_string().contains("invalid uuid"), "got: {err}");
778 }
779
780 #[tokio::test]
781 async fn run_delete_transaction_with_no_arg_emits_error() {
782 let err = run_delete_transaction(Uuid::nil(), None).await.unwrap_err();
783 assert!(err.to_string().contains("missing or empty"), "got: {err}");
784 }
785
786 #[tokio::test]
787 async fn run_delete_transaction_with_invalid_uuid_emits_error() {
788 let err = run_delete_transaction(Uuid::nil(), Some("not-uuid".into()))
789 .await
790 .unwrap_err();
791 assert!(err.to_string().contains("invalid uuid"), "got: {err}");
792 }
793
794 #[test]
795 fn parse_payload_minimal_two_splits() {
796 let src = r#"(:post-date "2026-01-15T00:00:00Z"
797 :splits ((:account-id "11111111-1111-1111-1111-111111111111"
798 :commodity-id "22222222-2222-2222-2222-222222222222"
799 :value -5000/100)
800 (:account-id "33333333-3333-3333-3333-333333333333"
801 :commodity-id "22222222-2222-2222-2222-222222222222"
802 :value 5000/100)))"#;
803 let parsed = parse_create_transaction_payload(src).expect("parse");
804 assert_eq!(parsed.splits.len(), 2);
805 assert_eq!(parsed.splits[0].value_num, -50);
806 assert_eq!(parsed.splits[0].value_denom, 1);
807 assert_eq!(parsed.splits[1].value_num, 50);
808 }
809
810 #[test]
811 fn parse_payload_picks_up_note() {
812 let src = r#"(:post-date "2026-01-15T00:00:00Z"
813 :note "groceries"
814 :splits ((:account-id "11111111-1111-1111-1111-111111111111"
815 :commodity-id "22222222-2222-2222-2222-222222222222"
816 :value -1)
817 (:account-id "33333333-3333-3333-3333-333333333333"
818 :commodity-id "22222222-2222-2222-2222-222222222222"
819 :value 1)))"#;
820 let parsed = parse_create_transaction_payload(src).expect("parse");
821 assert_eq!(parsed.note.as_deref(), Some("groceries"));
822 }
823
824 #[test]
825 fn parse_payload_rejects_missing_post_date() {
826 let src = r#"(:splits ((:account-id "x" :commodity-id "y" :value 1)
827 (:account-id "x" :commodity-id "y" :value -1)))"#;
828 let err = parse_create_transaction_payload(src).unwrap_err();
829 assert!(err.contains(":post-date"), "got: {err}");
830 }
831
832 #[test]
833 fn parse_payload_rejects_single_split() {
834 let src = r#"(:post-date "2026-01-15T00:00:00Z"
835 :splits ((:account-id "x" :commodity-id "y" :value 1)))"#;
836 let err = parse_create_transaction_payload(src).unwrap_err();
837 assert!(err.contains("at least two"), "got: {err}");
838 }
839
840 #[tokio::test]
841 async fn run_create_transaction_with_no_payload_emits_error() {
842 let err = run_create_transaction(Uuid::nil(), None).await.unwrap_err();
843 assert!(err.to_string().contains("missing or empty"), "got: {err}");
844 }
845
846 #[tokio::test]
847 async fn run_create_transaction_with_garbage_payload_surfaces_parse_error() {
848 let err = run_create_transaction(Uuid::nil(), Some("not-an-sexpr".into()))
849 .await
850 .unwrap_err();
851 assert!(err.to_string().contains("create-transaction"), "got: {err}");
852 }
853
854 #[test]
855 fn parse_update_payload_minimum_just_id() {
856 let src = r#"(:transaction-id "550e8400-e29b-41d4-a716-446655440000")"#;
857 let parsed = parse_update_transaction_payload(src).expect("parse");
858 assert_eq!(
859 parsed.transaction_id.to_string(),
860 "550e8400-e29b-41d4-a716-446655440000"
861 );
862 assert!(parsed.note.is_none());
863 assert!(parsed.splits.is_none());
864 }
865
866 #[test]
867 fn parse_update_payload_rejects_missing_transaction_id() {
868 let src = r#"(:note "x")"#;
869 let err = parse_update_transaction_payload(src).unwrap_err();
870 assert!(err.contains(":transaction-id"), "got: {err}");
871 }
872
873 #[test]
874 fn parse_update_payload_carries_partial_fields() {
875 let src = r#"(:transaction-id "550e8400-e29b-41d4-a716-446655440000"
876 :post-date "2026-05-11T12:00:00Z"
877 :note "edited")"#;
878 let parsed = parse_update_transaction_payload(src).expect("parse");
879 assert!(parsed.post_date.is_some());
880 assert_eq!(parsed.note.as_deref(), Some("edited"));
881 assert!(parsed.splits.is_none());
882 }
883
884 #[tokio::test]
885 async fn run_update_transaction_with_no_payload_emits_error() {
886 let err = run_update_transaction(Uuid::nil(), None).await.unwrap_err();
887 assert!(err.to_string().contains("missing or empty"), "got: {err}");
888 }
889
890 #[test]
891 fn format_pagination_has_more_emits_t() {
892 let pagination = PaginationInfo {
893 total_count: 100,
894 limit: 20,
895 offset: 0,
896 has_more: true,
897 };
898 let out = format_tagged_transactions(&[], Some(&pagination));
899 assert!(out.contains(":has-more t)"));
900 }
901}