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

            
8
use chrono::{DateTime, Utc};
9
use finance::split::Split;
10
use finance::tag::Tag;
11
use nomiscript::{Expr, Fraction, Reader};
12
use scripting::runtime::{
13
    alloc_entity_via_export, alloc_pair_chain, alloc_string_ref, read_string_arg,
14
};
15
#[cfg(test)]
16
use server::command::PaginationInfo;
17
use server::command::transaction::{
18
    CreateTransaction, DeleteTransaction, GetTransaction, GetTransactionTag, ListTransactions,
19
    SetTransactionTag, UpdateTransaction,
20
};
21
use server::command::{CmdError, CmdResult, FinanceEntity};
22
use uuid::Uuid;
23
use wasmtime::{AnyRef, ArrayRef, Caller, Linker, Rooted, StructRef, Val};
24

            
25
use crate::session::SessionData;
26

            
27
pub 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

            
37
2559
pub fn register(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
38
2559
    register_readonly(linker)?;
39
2559
    register_mutators(linker)?;
40
2559
    Ok(())
41
2559
}
42

            
43
2660
pub fn register_readonly(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
44
2660
    linker.func_wrap_async(
45
2660
        "nomi",
46
2660
        "transaction_list_transactions",
47
        |mut caller: Caller<'_, SessionData>,
48
         ()|
49
         -> Box<
50
            dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
51
54
        > {
52
54
            Box::new(async move {
53
54
                let user_id = caller.data().ctx().user_id;
54
54
                let result = ListTransactions::new().user_id(user_id).run().await;
55
54
                let entries = list_transaction_entries("list-transactions", result)?;
56
54
                alloc_transaction_chain(&mut caller, entries).await
57
54
            })
58
54
        },
59
    )?;
60
2660
    linker.func_wrap_async(
61
2660
        "nomi",
62
2660
        "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
36
        > {
68
36
            Box::new(async move {
69
36
                let user_id = caller.data().ctx().user_id;
70
36
                let id = read_string_arg(&mut caller, id_arg)?;
71
36
                run_get_transaction(&mut caller, user_id, id).await
72
36
            })
73
36
        },
74
    )?;
75
2660
    linker.func_wrap_async(
76
2660
        "nomi",
77
2660
        "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
2660
    Ok(())
93
2660
}
94

            
95
2559
pub fn register_mutators(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
96
2559
    linker.func_wrap_async(
97
2559
        "nomi",
98
2559
        "transaction_delete_transaction",
99
        |mut caller: Caller<'_, SessionData>,
100
         (id_arg,): (Option<Rooted<ArrayRef>>,)|
101
18
         -> Box<dyn std::future::Future<Output = wasmtime::Result<i32>> + Send> {
102
18
            Box::new(async move {
103
18
                let user_id = caller.data().ctx().user_id;
104
18
                let id = read_string_arg(&mut caller, id_arg)?;
105
18
                run_delete_transaction(user_id, id).await
106
18
            })
107
18
        },
108
    )?;
109
2559
    linker.func_wrap_async(
110
2559
        "nomi",
111
2559
        "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
234
        > {
117
234
            Box::new(async move {
118
234
                let user_id = caller.data().ctx().user_id;
119
234
                let payload = read_string_arg(&mut caller, payload_arg)?;
120
234
                let id = run_create_transaction(user_id, payload).await?;
121
234
                Ok(Some(alloc_string_ref(&mut caller, id.as_bytes())?))
122
234
            })
123
234
        },
124
    )?;
125
2559
    linker.func_wrap_async(
126
2559
        "nomi",
127
2559
        "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
18
        > {
133
18
            Box::new(async move {
134
18
                let user_id = caller.data().ctx().user_id;
135
18
                let payload = read_string_arg(&mut caller, payload_arg)?;
136
18
                let id = run_update_transaction(user_id, payload).await?;
137
                Ok(Some(alloc_string_ref(&mut caller, id.as_bytes())?))
138
18
            })
139
18
        },
140
    )?;
141
2559
    linker.func_wrap_async(
142
2559
        "nomi",
143
2559
        "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
2559
    Ok(())
157
2559
}
158

            
159
/// `set-transaction-tag` upsert (idempotent). Mirrors `set-split-tag`
160
/// and `set-account-tag` — i32 return = 1 on success.
161
async 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`.
191
async 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).
227
19
async fn run_update_transaction(
228
19
    user_id: Uuid,
229
19
    payload_arg: Option<String>,
230
19
) -> wasmtime::Result<String> {
231
19
    let payload = payload_arg
232
19
        .filter(|s| !s.is_empty())
233
19
        .ok_or_else(|| wasmtime::Error::msg("update-transaction: missing or empty :payload arg"))?;
234
18
    let input = parse_update_transaction_payload(&payload)
235
18
        .map_err(|err| wasmtime::Error::msg(format!("update-transaction: {err}")))?;
236
18
    let mut runner = UpdateTransaction::new()
237
18
        .user_id(user_id)
238
18
        .transaction_id(input.transaction_id);
239
18
    if let Some(post_date) = input.post_date {
240
        runner = runner.post_date(post_date);
241
18
    }
242
18
    if let Some(enter_date) = input.enter_date {
243
        runner = runner.enter_date(enter_date);
244
18
    }
245
18
    if let Some(note) = input.note {
246
18
        runner = runner.note(note);
247
18
    }
248
18
    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
18
    }
258
18
    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
18
        Err(err) => Err(wasmtime::Error::msg(format!("update-transaction: {err}"))),
267
    }
268
19
}
269

            
270
#[derive(Debug)]
271
struct 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

            
279
21
fn parse_update_transaction_payload(src: &str) -> Result<UpdateTransactionInput, String> {
280
21
    let program = Reader::parse(src).map_err(|err| format!("payload parse: {err}"))?;
281
21
    let first = program
282
21
        .exprs
283
21
        .into_iter()
284
21
        .next()
285
21
        .ok_or_else(|| "payload empty".to_string())?;
286
21
    let plist = expect_plist(first, "payload")?;
287
21
    let tx_raw = take_plist_string(&plist, "transaction-id")?
288
21
        .ok_or_else(|| "payload: missing :transaction-id".to_string())?;
289
20
    let transaction_id = Uuid::parse_str(&tx_raw)
290
20
        .map_err(|err| format!("payload: invalid :transaction-id '{tx_raw}': {err}"))?;
291
20
    let post_date = match take_plist_string(&plist, "post-date")? {
292
1
        Some(raw) => Some(
293
1
            DateTime::parse_from_rfc3339(&raw)
294
1
                .map(|d| d.with_timezone(&Utc))
295
1
                .map_err(|err| format!("payload: invalid :post-date '{raw}': {err}"))?,
296
        ),
297
19
        None => None,
298
    };
299
20
    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
20
        None => None,
306
    };
307
20
    let note = take_plist_string(&plist, "note")?;
308
20
    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
20
        None => None,
321
    };
322
20
    Ok(UpdateTransactionInput {
323
20
        transaction_id,
324
20
        post_date,
325
20
        enter_date,
326
20
        note,
327
20
        splits,
328
20
    })
329
21
}
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)]
339
struct CreateTransactionInput {
340
    id: Uuid,
341
    post_date: DateTime<Utc>,
342
    enter_date: DateTime<Utc>,
343
    note: Option<String>,
344
    splits: Vec<Split>,
345
}
346

            
347
236
async fn run_create_transaction(
348
236
    user_id: Uuid,
349
236
    payload_arg: Option<String>,
350
236
) -> wasmtime::Result<String> {
351
236
    let payload = payload_arg
352
236
        .filter(|s| !s.is_empty())
353
236
        .ok_or_else(|| wasmtime::Error::msg("create-transaction: missing or empty :payload arg"))?;
354
235
    let input = parse_create_transaction_payload(&payload)
355
235
        .map_err(|err| wasmtime::Error::msg(format!("create-transaction: {err}")))?;
356
234
    let mut splits: Vec<FinanceEntity> = input
357
234
        .splits
358
234
        .into_iter()
359
486
        .map(|mut s| {
360
486
            s.tx_id = input.id;
361
486
            FinanceEntity::Split(s)
362
486
        })
363
234
        .collect();
364
234
    let mut runner = CreateTransaction::new()
365
234
        .user_id(user_id)
366
234
        .id(input.id)
367
234
        .post_date(input.post_date)
368
234
        .enter_date(input.enter_date)
369
234
        .splits(std::mem::take(&mut splits));
370
234
    if let Some(note) = input.note {
371
234
        runner = runner.note(note);
372
234
    }
373
234
    match runner.run().await {
374
234
        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
236
}
384

            
385
239
fn parse_create_transaction_payload(src: &str) -> Result<CreateTransactionInput, String> {
386
239
    let program = Reader::parse(src).map_err(|err| format!("payload parse: {err}"))?;
387
239
    let first = program
388
239
        .exprs
389
239
        .into_iter()
390
239
        .next()
391
239
        .ok_or_else(|| "payload empty".to_string())?;
392
239
    let plist = expect_plist(first, "payload")?;
393
238
    let post_date_raw = take_plist_string(&plist, "post-date")?
394
238
        .ok_or_else(|| "payload: missing :post-date".to_string())?;
395
237
    let post_date = DateTime::parse_from_rfc3339(&post_date_raw)
396
237
        .map(|d| d.with_timezone(&Utc))
397
237
        .map_err(|err| format!("payload: invalid :post-date '{post_date_raw}': {err}"))?;
398
237
    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
237
        None => Utc::now(),
403
    };
404
237
    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
237
        None => Uuid::new_v4(),
409
    };
410
237
    let note = take_plist_string(&plist, "note")?;
411
237
    let splits_list =
412
237
        take_plist_list(&plist, "splits")?.ok_or_else(|| "payload: missing :splits".to_string())?;
413
237
    if splits_list.len() < 2 {
414
1
        return Err("payload: :splits must have at least two entries".into());
415
236
    }
416
236
    let splits = splits_list
417
236
        .into_iter()
418
236
        .enumerate()
419
490
        .map(|(idx, expr)| parse_split_plist(expr, idx))
420
236
        .collect::<Result<Vec<_>, _>>()?;
421
236
    Ok(CreateTransactionInput {
422
236
        id,
423
236
        post_date,
424
236
        enter_date,
425
236
        note,
426
236
        splits,
427
236
    })
428
239
}
429

            
430
490
fn parse_split_plist(expr: Expr, idx: usize) -> Result<Split, String> {
431
490
    let plist = expect_plist(expr, &format!("split[{idx}]"))?;
432
490
    let account_raw = take_plist_string(&plist, "account-id")?
433
490
        .ok_or_else(|| format!("split[{idx}]: missing :account-id"))?;
434
490
    let account_id = Uuid::parse_str(&account_raw)
435
490
        .map_err(|err| format!("split[{idx}]: invalid :account-id '{account_raw}': {err}"))?;
436
490
    let commodity_raw = take_plist_string(&plist, "commodity-id")?
437
490
        .ok_or_else(|| format!("split[{idx}]: missing :commodity-id"))?;
438
490
    let commodity_id = Uuid::parse_str(&commodity_raw)
439
490
        .map_err(|err| format!("split[{idx}]: invalid :commodity-id '{commodity_raw}': {err}"))?;
440
490
    let value = take_plist_number(&plist, "value")?
441
490
        .ok_or_else(|| format!("split[{idx}]: missing :value"))?;
442
490
    Ok(Split {
443
490
        id: Uuid::new_v4(),
444
490
        tx_id: Uuid::nil(),
445
490
        account_id,
446
490
        commodity_id,
447
490
        reconcile_state: None,
448
490
        reconcile_date: None,
449
490
        value_num: *value.numer(),
450
490
        value_denom: *value.denom(),
451
490
        lot_id: None,
452
490
    })
453
490
}
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.
459
750
fn expect_plist(expr: Expr, context: &str) -> Result<Vec<(String, Expr)>, String> {
460
750
    let items = match expr {
461
749
        Expr::List(items) => items,
462
1
        other => return Err(format!("{context}: expected plist, got {other:?}")),
463
    };
464
749
    if !items.len().is_multiple_of(2) {
465
        return Err(format!(
466
            "{context}: plist has odd number of elements ({})",
467
            items.len()
468
        ));
469
749
    }
470
749
    let mut out = Vec::with_capacity(items.len() / 2);
471
749
    let mut iter = items.into_iter();
472
2970
    while let Some(key) = iter.next() {
473
2221
        let key_name = match key {
474
2221
            Expr::Keyword(name) => name,
475
            other => return Err(format!("{context}: expected :keyword, got {other:?}")),
476
        };
477
2221
        let value = iter
478
2221
            .next()
479
2221
            .ok_or_else(|| format!("{context}: dangling :{key_name} without value"))?;
480
2221
        out.push((key_name, value));
481
    }
482
749
    Ok(out)
483
750
}
484

            
485
2010
fn take_plist_string(plist: &[(String, Expr)], key: &str) -> Result<Option<String>, String> {
486
2010
    let upper = key.to_ascii_uppercase();
487
3740
    match plist.iter().find(|(k, _)| k.eq_ignore_ascii_case(&upper)) {
488
1492
        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
518
        None => Ok(None),
492
    }
493
2010
}
494

            
495
490
fn take_plist_number(plist: &[(String, Expr)], key: &str) -> Result<Option<Fraction>, String> {
496
490
    let upper = key.to_ascii_uppercase();
497
1470
    match plist.iter().find(|(k, _)| k.eq_ignore_ascii_case(&upper)) {
498
490
        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
490
}
504

            
505
257
fn take_plist_list(plist: &[(String, Expr)], key: &str) -> Result<Option<Vec<Expr>>, String> {
506
257
    let upper = key.to_ascii_uppercase();
507
749
    match plist.iter().find(|(k, _)| k.eq_ignore_ascii_case(&upper)) {
508
237
        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
20
        None => Ok(None),
512
    }
513
257
}
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.
519
20
async fn run_delete_transaction(user_id: Uuid, id_arg: Option<String>) -> wasmtime::Result<i32> {
520
20
    let raw = id_arg.filter(|s| !s.is_empty()).ok_or_else(|| {
521
1
        wasmtime::Error::msg("delete-transaction: missing or empty :transaction-id arg")
522
1
    })?;
523
19
    let transaction_id = Uuid::parse_str(&raw).map_err(|err| {
524
1
        wasmtime::Error::msg(format!("delete-transaction: invalid uuid '{raw}': {err}"))
525
1
    })?;
526
18
    DeleteTransaction::new()
527
18
        .user_id(user_id)
528
18
        .transaction_id(transaction_id)
529
18
        .run()
530
18
        .await
531
18
        .map(|_| 1)
532
18
        .map_err(|err| wasmtime::Error::msg(format!("delete-transaction: {err}")))
533
20
}
534

            
535
36
async fn run_get_transaction(
536
36
    caller: &mut Caller<'_, SessionData>,
537
36
    user_id: Uuid,
538
36
    id_arg: Option<String>,
539
36
) -> wasmtime::Result<Option<Rooted<StructRef>>> {
540
36
    let transaction_id = parse_transaction_id_arg(id_arg)?;
541
36
    let result = GetTransaction::new()
542
36
        .user_id(user_id)
543
36
        .transaction_id(transaction_id)
544
36
        .run()
545
36
        .await;
546
36
    let entries = list_transaction_entries("get-transaction", result)?;
547
36
    match entries.into_iter().next() {
548
18
        Some((id, note, post_date)) => Ok(Some(
549
18
            alloc_transaction_entity(caller, &id, note.as_deref(), Some(&post_date)).await?,
550
        )),
551
18
        None => Ok(None),
552
    }
553
36
}
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`.
558
38
fn parse_transaction_id_arg(id_arg: Option<String>) -> wasmtime::Result<Uuid> {
559
38
    let raw = id_arg.filter(|s| !s.is_empty()).ok_or_else(|| {
560
1
        wasmtime::Error::msg("get-transaction: missing or empty :transaction-id arg")
561
1
    })?;
562
37
    Uuid::parse_str(&raw).map_err(|err| {
563
1
        wasmtime::Error::msg(format!("get-transaction: invalid uuid '{raw}': {err}"))
564
1
    })
565
38
}
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.
571
90
fn list_transaction_entries(
572
90
    name: &str,
573
90
    result: Result<Option<CmdResult>, CmdError>,
574
90
) -> wasmtime::Result<Vec<(String, Option<String>, String)>> {
575
72
    match result {
576
72
        Ok(Some(CmdResult::TaggedEntities { entities, .. })) => Ok(entities
577
72
            .into_iter()
578
126
            .filter_map(|(entity, tags)| match entity {
579
126
                FinanceEntity::Transaction(tx) => Some((
580
126
                    tx.id.to_string(),
581
126
                    tag_value(&tags, "note").map(str::to_string),
582
126
                    tx.post_date.to_rfc3339(),
583
126
                )),
584
                _ => None,
585
126
            })
586
72
            .collect()),
587
        Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
588
            "{name}: expected TaggedEntities, got {other:?}"
589
        ))),
590
18
        Ok(None) => Ok(Vec::new()),
591
        Err(err) => Err(wasmtime::Error::msg(format!("{name}: {err}"))),
592
    }
593
90
}
594

            
595
126
async fn alloc_transaction_entity(
596
126
    caller: &mut Caller<'_, SessionData>,
597
126
    id: &str,
598
126
    note: Option<&str>,
599
126
    post_date: Option<&str>,
600
126
) -> wasmtime::Result<Rooted<StructRef>> {
601
126
    let id_ref = alloc_string_ref(caller, id.as_bytes())?;
602
126
    let note_ref = match note {
603
126
        Some(s) => Some(alloc_string_ref(caller, s.as_bytes())?),
604
        None => None,
605
    };
606
126
    let date_ref = match post_date {
607
126
        Some(s) => Some(alloc_string_ref(caller, s.as_bytes())?),
608
        None => None,
609
    };
610
126
    let args = [
611
126
        Val::AnyRef(Some(id_ref.to_anyref())),
612
126
        Val::AnyRef(note_ref.map(|r| r.to_anyref())),
613
126
        Val::AnyRef(date_ref.map(|r| r.to_anyref())),
614
    ];
615
126
    alloc_entity_via_export(caller, "alloc_transaction", &args).await
616
126
}
617

            
618
54
async fn alloc_transaction_chain(
619
54
    caller: &mut Caller<'_, SessionData>,
620
54
    entries: Vec<(String, Option<String>, String)>,
621
54
) -> wasmtime::Result<Option<Rooted<StructRef>>> {
622
54
    let mut anyrefs: Vec<Rooted<AnyRef>> = Vec::with_capacity(entries.len());
623
108
    for (id, note, post_date) in entries {
624
108
        let entity_ref =
625
108
            alloc_transaction_entity(caller, &id, note.as_deref(), Some(&post_date)).await?;
626
108
        anyrefs.push(entity_ref.to_anyref());
627
    }
628
54
    alloc_pair_chain(caller, anyrefs).await
629
54
}
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)]
636
4
fn format_tagged_transactions(
637
4
    entities: &[(
638
4
        FinanceEntity,
639
4
        std::collections::HashMap<String, FinanceEntity>,
640
4
    )],
641
4
    pagination: Option<&PaginationInfo>,
642
4
) -> String {
643
4
    let mut out = String::from("(:transactions (");
644
4
    for (idx, (entity, tags)) in entities.iter().enumerate() {
645
2
        if idx > 0 {
646
            out.push(' ');
647
2
        }
648
2
        match entity {
649
2
            FinanceEntity::Transaction(tx) => {
650
2
                out.push_str(&format!(
651
2
                    "(:id \"{}\" :post-date \"{}\" :enter-date \"{}\"",
652
2
                    tx.id,
653
2
                    tx.post_date.to_rfc3339(),
654
2
                    tx.enter_date.to_rfc3339()
655
2
                ));
656
2
                if let Some(note) = tag_value(tags, "note") {
657
1
                    out.push_str(&format!(" :note {}", quote_string(note)));
658
1
                }
659
2
                out.push(')');
660
            }
661
            other => {
662
                out.push_str(&format!("(:error \"unexpected entity {other:?}\")"));
663
            }
664
        }
665
    }
666
4
    out.push_str(") :pagination ");
667
4
    match pagination {
668
2
        Some(p) => out.push_str(&format!(
669
            "(:total {} :limit {} :offset {} :has-more {})",
670
            p.total_count,
671
            p.limit,
672
            p.offset,
673
2
            if p.has_more { "t" } else { "nil" }
674
        )),
675
2
        None => out.push_str("nil"),
676
    }
677
4
    out.push(')');
678
4
    out
679
4
}
680

            
681
128
fn tag_value<'a>(
682
128
    tags: &'a std::collections::HashMap<String, FinanceEntity>,
683
128
    key: &str,
684
128
) -> Option<&'a str> {
685
128
    tags.get(key).and_then(|t| match t {
686
127
        FinanceEntity::Tag(Tag { tag_value, .. }) => Some(tag_value.as_str()),
687
        _ => None,
688
127
    })
689
128
}
690

            
691
#[cfg(test)]
692
1
fn quote_string(s: &str) -> String {
693
1
    let mut q = String::with_capacity(s.len() + 2);
694
1
    q.push('"');
695
9
    for ch in s.chars() {
696
9
        match ch {
697
            '"' => q.push_str("\\\""),
698
            '\\' => q.push_str("\\\\"),
699
9
            other => q.push(other),
700
        }
701
    }
702
1
    q.push('"');
703
1
    q
704
1
}
705

            
706
#[cfg(test)]
707
mod tests {
708
    use super::*;
709
    use chrono::TimeZone;
710
    use finance::transaction::Transaction;
711
    use std::collections::HashMap;
712
    use uuid::Uuid;
713

            
714
2
    fn tx_entity(id: Uuid) -> FinanceEntity {
715
2
        let post = chrono::Utc.with_ymd_and_hms(2026, 5, 1, 12, 0, 0).unwrap();
716
2
        let enter = chrono::Utc.with_ymd_and_hms(2026, 5, 2, 9, 30, 0).unwrap();
717
2
        FinanceEntity::Transaction(Transaction {
718
2
            id,
719
2
            post_date: post,
720
2
            enter_date: enter,
721
2
        })
722
2
    }
723

            
724
    #[test]
725
1
    fn format_empty_list_with_no_pagination() {
726
1
        assert_eq!(
727
1
            format_tagged_transactions(&[], None),
728
            "(:transactions () :pagination nil)"
729
        );
730
1
    }
731

            
732
    #[test]
733
1
    fn format_single_transaction_with_note_and_pagination() {
734
1
        let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
735
1
        let mut tags = HashMap::new();
736
1
        tags.insert(
737
1
            "note".to_string(),
738
1
            FinanceEntity::Tag(Tag {
739
1
                id: Uuid::nil(),
740
1
                tag_name: "note".into(),
741
1
                tag_value: "groceries".into(),
742
1
                description: None,
743
1
            }),
744
        );
745
1
        let pagination = PaginationInfo {
746
1
            total_count: 1,
747
1
            limit: 20,
748
1
            offset: 0,
749
1
            has_more: false,
750
1
        };
751
1
        let out = format_tagged_transactions(&[(tx_entity(id), tags)], Some(&pagination));
752
1
        assert!(out.contains(":id \"550e8400-e29b-41d4-a716-446655440000\""));
753
1
        assert!(out.contains(":post-date \"2026-05-01T12:00:00+00:00\""));
754
1
        assert!(out.contains(":enter-date \"2026-05-02T09:30:00+00:00\""));
755
1
        assert!(out.contains(":note \"groceries\""));
756
1
        assert!(out.contains(":pagination (:total 1 :limit 20 :offset 0 :has-more nil)"));
757
1
    }
758

            
759
    #[test]
760
1
    fn format_transaction_without_tags_omits_note() {
761
1
        let id = Uuid::nil();
762
1
        let out = format_tagged_transactions(&[(tx_entity(id), HashMap::new())], None);
763
1
        assert!(out.contains(":id \"00000000-0000-0000-0000-000000000000\""));
764
1
        assert!(!out.contains(":note"));
765
1
        assert!(out.ends_with(":pagination nil)"));
766
1
    }
767

            
768
    #[test]
769
1
    fn parse_transaction_id_rejects_missing_arg() {
770
1
        let err = parse_transaction_id_arg(None).unwrap_err();
771
1
        assert!(err.to_string().contains("missing or empty"), "got: {err}");
772
1
    }
773

            
774
    #[test]
775
1
    fn parse_transaction_id_rejects_invalid_uuid() {
776
1
        let err = parse_transaction_id_arg(Some("not-a-uuid".into())).unwrap_err();
777
1
        assert!(err.to_string().contains("invalid uuid"), "got: {err}");
778
1
    }
779

            
780
    #[tokio::test]
781
1
    async fn run_delete_transaction_with_no_arg_emits_error() {
782
1
        let err = run_delete_transaction(Uuid::nil(), None).await.unwrap_err();
783
1
        assert!(err.to_string().contains("missing or empty"), "got: {err}");
784
1
    }
785

            
786
    #[tokio::test]
787
1
    async fn run_delete_transaction_with_invalid_uuid_emits_error() {
788
1
        let err = run_delete_transaction(Uuid::nil(), Some("not-uuid".into()))
789
1
            .await
790
1
            .unwrap_err();
791
1
        assert!(err.to_string().contains("invalid uuid"), "got: {err}");
792
1
    }
793

            
794
    #[test]
795
1
    fn parse_payload_minimal_two_splits() {
796
1
        let src = r#"(:post-date "2026-01-15T00:00:00Z"
797
1
            :splits ((:account-id "11111111-1111-1111-1111-111111111111"
798
1
                      :commodity-id "22222222-2222-2222-2222-222222222222"
799
1
                      :value -5000/100)
800
1
                     (:account-id "33333333-3333-3333-3333-333333333333"
801
1
                      :commodity-id "22222222-2222-2222-2222-222222222222"
802
1
                      :value 5000/100)))"#;
803
1
        let parsed = parse_create_transaction_payload(src).expect("parse");
804
1
        assert_eq!(parsed.splits.len(), 2);
805
1
        assert_eq!(parsed.splits[0].value_num, -50);
806
1
        assert_eq!(parsed.splits[0].value_denom, 1);
807
1
        assert_eq!(parsed.splits[1].value_num, 50);
808
1
    }
809

            
810
    #[test]
811
1
    fn parse_payload_picks_up_note() {
812
1
        let src = r#"(:post-date "2026-01-15T00:00:00Z"
813
1
            :note "groceries"
814
1
            :splits ((:account-id "11111111-1111-1111-1111-111111111111"
815
1
                      :commodity-id "22222222-2222-2222-2222-222222222222"
816
1
                      :value -1)
817
1
                     (:account-id "33333333-3333-3333-3333-333333333333"
818
1
                      :commodity-id "22222222-2222-2222-2222-222222222222"
819
1
                      :value 1)))"#;
820
1
        let parsed = parse_create_transaction_payload(src).expect("parse");
821
1
        assert_eq!(parsed.note.as_deref(), Some("groceries"));
822
1
    }
823

            
824
    #[test]
825
1
    fn parse_payload_rejects_missing_post_date() {
826
1
        let src = r#"(:splits ((:account-id "x" :commodity-id "y" :value 1)
827
1
                                (:account-id "x" :commodity-id "y" :value -1)))"#;
828
1
        let err = parse_create_transaction_payload(src).unwrap_err();
829
1
        assert!(err.contains(":post-date"), "got: {err}");
830
1
    }
831

            
832
    #[test]
833
1
    fn parse_payload_rejects_single_split() {
834
1
        let src = r#"(:post-date "2026-01-15T00:00:00Z"
835
1
                      :splits ((:account-id "x" :commodity-id "y" :value 1)))"#;
836
1
        let err = parse_create_transaction_payload(src).unwrap_err();
837
1
        assert!(err.contains("at least two"), "got: {err}");
838
1
    }
839

            
840
    #[tokio::test]
841
1
    async fn run_create_transaction_with_no_payload_emits_error() {
842
1
        let err = run_create_transaction(Uuid::nil(), None).await.unwrap_err();
843
1
        assert!(err.to_string().contains("missing or empty"), "got: {err}");
844
1
    }
845

            
846
    #[tokio::test]
847
1
    async fn run_create_transaction_with_garbage_payload_surfaces_parse_error() {
848
1
        let err = run_create_transaction(Uuid::nil(), Some("not-an-sexpr".into()))
849
1
            .await
850
1
            .unwrap_err();
851
1
        assert!(err.to_string().contains("create-transaction"), "got: {err}");
852
1
    }
853

            
854
    #[test]
855
1
    fn parse_update_payload_minimum_just_id() {
856
1
        let src = r#"(:transaction-id "550e8400-e29b-41d4-a716-446655440000")"#;
857
1
        let parsed = parse_update_transaction_payload(src).expect("parse");
858
1
        assert_eq!(
859
1
            parsed.transaction_id.to_string(),
860
            "550e8400-e29b-41d4-a716-446655440000"
861
        );
862
1
        assert!(parsed.note.is_none());
863
1
        assert!(parsed.splits.is_none());
864
1
    }
865

            
866
    #[test]
867
1
    fn parse_update_payload_rejects_missing_transaction_id() {
868
1
        let src = r#"(:note "x")"#;
869
1
        let err = parse_update_transaction_payload(src).unwrap_err();
870
1
        assert!(err.contains(":transaction-id"), "got: {err}");
871
1
    }
872

            
873
    #[test]
874
1
    fn parse_update_payload_carries_partial_fields() {
875
1
        let src = r#"(:transaction-id "550e8400-e29b-41d4-a716-446655440000"
876
1
                      :post-date "2026-05-11T12:00:00Z"
877
1
                      :note "edited")"#;
878
1
        let parsed = parse_update_transaction_payload(src).expect("parse");
879
1
        assert!(parsed.post_date.is_some());
880
1
        assert_eq!(parsed.note.as_deref(), Some("edited"));
881
1
        assert!(parsed.splits.is_none());
882
1
    }
883

            
884
    #[tokio::test]
885
1
    async fn run_update_transaction_with_no_payload_emits_error() {
886
1
        let err = run_update_transaction(Uuid::nil(), None).await.unwrap_err();
887
1
        assert!(err.to_string().contains("missing or empty"), "got: {err}");
888
1
    }
889

            
890
    #[test]
891
1
    fn format_pagination_has_more_emits_t() {
892
1
        let pagination = PaginationInfo {
893
1
            total_count: 100,
894
1
            limit: 20,
895
1
            offset: 0,
896
1
            has_more: true,
897
1
        };
898
1
        let out = format_tagged_transactions(&[], Some(&pagination));
899
1
        assert!(out.contains(":has-more t)"));
900
1
    }
901
}