Skip to main content

rpc/natives/
transaction.rs

1//! Transaction-domain natives. Wraps `server::command::{CreateTransaction,
2//! ListTransactions, GetTransaction, UpdateTransaction, DeleteTransaction}`.
3//!
4//! v1 binds the no-arg form of `list-transactions`. Filtering keyword args
5//! (account / limit / offset / date_from / date_to) ride a follow-up slice
6//! once the keyword-arg threading helper is shared across read commands.
7
8use 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
159/// `set-transaction-tag` upsert (idempotent). Mirrors `set-split-tag`
160/// and `set-account-tag` — i32 return = 1 on success.
161async 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
189/// `get-transaction-tag` lookup. Empty-string return on absence — same
190/// shape as `get-split-tag`.
191async 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
221/// Companion to create-transaction. Payload mirrors the create shape but
222/// `:transaction-id` is required (the row being updated) and every other
223/// field is optional — caller only supplies what they want changed.
224/// Splits when present REPLACE the existing set; partial split updates
225/// aren't representable in this slice (the server's UpdateTransaction
226/// runner treats `splits` as a full-list replace).
227async 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/// Parses the compound S-expr plist into the typed arguments
332/// CreateTransaction needs. Keeps the wire format aligned with the rest
333/// of the rpc surface — same Reader the envelope parser uses, same
334/// keyword-plist shape readers see across natives. Skipping :prices /
335/// :tags / :enter-date / :id for v1: the most common path (post a
336/// single-commodity transaction with a couple splits and a note) lands;
337/// the optional fields ride follow-up slices.
338#[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
455/// Returns the (keyword, value) pairs the plist carries. The outer list
456/// is interpreted as a flat plist: every even-index element must be a
457/// `Keyword`; the following element is its value. Anything else is a
458/// shape error.
459fn 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
515/// Removes the transaction by UUID along with its splits, prices, and
516/// owned tags. Not idempotent — server returns an error when the row
517/// isn't there (vs remove-ssh-key which collapses to Ok). Returns `t`
518/// on success.
519async 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
555/// Parses + validates the :transaction-id arg into a Uuid. Extracted so
556/// the validation contract stays callable from unit tests that can't
557/// construct a wasmtime `Caller`.
558fn 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
567/// Per-transaction (id, note-tag, post-date) triples. Pagination metadata
568/// retires for v1; emacs clients consume the typed pair chain directly.
569/// Filter-side `list-transactions :limit/:offset` keywords land in a
570/// follow-up alongside the broader keyword-arg threading.
571fn 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/// Test-only legacy renderer; production paths now ship typed
632/// `pair<transaction>` / `EntityRef(Transaction)` via the alloc helpers.
633/// Retained for the existing format-assertion tests until A6 collapses
634/// the streaming-string capture protocol.
635#[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}