1
use finance::tag::Tag;
2
use num_rational::Rational64;
3
use scripting::runtime::{alloc_entity_via_export, alloc_pair_chain, alloc_string_ref};
4
use server::command::{CmdError, CmdResult, FinanceEntity};
5
use wasmtime::{AnyRef, Caller, Rooted, StructRef, Val};
6

            
7
use crate::session::SessionData;
8

            
9
/// One listed transaction: `(id, note, amount, post-date)`.
10
pub(super) type TransactionEntry = (String, Option<String>, Option<String>, String);
11

            
12
228
pub(super) fn list_transaction_entries(
13
228
    name: &str,
14
228
    result: Result<Option<CmdResult>, CmdError>,
15
228
) -> wasmtime::Result<Vec<TransactionEntry>> {
16
203
    match result {
17
203
        Ok(Some(CmdResult::TaggedTransactions { entities, .. })) => Ok(entities
18
203
            .into_iter()
19
303
            .filter_map(|(entity, tags, amount)| match entity {
20
303
                FinanceEntity::Transaction(tx) => Some((
21
303
                    tx.id.to_string(),
22
303
                    tag_value(&tags, "note").map(str::to_string),
23
303
                    amount,
24
303
                    tx.post_date.to_rfc3339(),
25
303
                )),
26
                _ => None,
27
303
            })
28
203
            .collect()),
29
        Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
30
            "{name}: expected TaggedTransactions, got {other:?}"
31
        ))),
32
25
        Ok(None) => Ok(Vec::new()),
33
        Err(err) => Err(wasmtime::Error::msg(format!("{name}: {err}"))),
34
    }
35
228
}
36

            
37
300
pub(super) async fn alloc_transaction_entity(
38
300
    caller: &mut Caller<'_, SessionData>,
39
300
    id: &str,
40
300
    note: Option<&str>,
41
300
    amount: Option<&str>,
42
300
    post_date: Option<&str>,
43
300
) -> wasmtime::Result<Rooted<StructRef>> {
44
300
    let id_ref = alloc_string_ref(caller, id.as_bytes())?;
45
300
    let note_ref = note
46
300
        .map(|s| alloc_string_ref(caller, s.as_bytes()))
47
300
        .transpose()?;
48
300
    let amount_ref = amount
49
300
        .map(|s| alloc_string_ref(caller, s.as_bytes()))
50
300
        .transpose()?;
51
300
    let date_ref = post_date
52
300
        .map(|s| alloc_string_ref(caller, s.as_bytes()))
53
300
        .transpose()?;
54
300
    let args = [
55
300
        Val::AnyRef(Some(id_ref.to_anyref())),
56
300
        Val::AnyRef(note_ref.map(|r| r.to_anyref())),
57
300
        Val::AnyRef(amount_ref.map(|r| r.to_anyref())),
58
300
        Val::AnyRef(date_ref.map(|r| r.to_anyref())),
59
    ];
60
300
    alloc_entity_via_export(caller, "alloc_transaction", &args).await
61
300
}
62

            
63
175
pub(super) async fn alloc_transaction_chain(
64
175
    caller: &mut Caller<'_, SessionData>,
65
175
    entries: Vec<TransactionEntry>,
66
175
) -> wasmtime::Result<Option<Rooted<StructRef>>> {
67
175
    let mut anyrefs: Vec<Rooted<AnyRef>> = Vec::with_capacity(entries.len());
68
275
    for (id, note, amount, post_date) in entries {
69
275
        let entity_ref = alloc_transaction_entity(
70
275
            caller,
71
275
            &id,
72
275
            note.as_deref(),
73
275
            amount.as_deref(),
74
275
            Some(&post_date),
75
275
        )
76
275
        .await?;
77
275
        anyrefs.push(entity_ref.to_anyref());
78
    }
79
175
    alloc_pair_chain(caller, anyrefs).await
80
175
}
81

            
82
307
pub(super) fn tag_value<'a>(
83
307
    tags: &'a std::collections::HashMap<String, FinanceEntity>,
84
307
    key: &str,
85
307
) -> Option<&'a str> {
86
307
    tags.get(key).and_then(|t| match t {
87
203
        FinanceEntity::Tag(Tag { tag_value, .. }) => Some(tag_value.as_str()),
88
        _ => None,
89
203
    })
90
307
}
91

            
92
/// Escape a string for nomiscript plist output.
93
2
pub(super) fn quote_string(s: &str) -> String {
94
2
    let mut q = String::with_capacity(s.len() + 2);
95
2
    q.push('"');
96
16
    for ch in s.chars() {
97
16
        match ch {
98
            '"' => q.push_str("\\\""),
99
            '\\' => q.push_str("\\\\"),
100
16
            other => q.push(other),
101
        }
102
    }
103
2
    q.push('"');
104
2
    q
105
2
}
106

            
107
4
fn format_ratio(num: i64, denom: i64) -> String {
108
4
    let r = Rational64::new(num, denom);
109
4
    if *r.denom() == 1 {
110
4
        format!("{}", r.numer())
111
    } else {
112
        format!("{}/{}", r.numer(), r.denom())
113
    }
114
4
}
115

            
116
/// Render a full transaction plist from a `GetTransactionDetail` result.
117
///
118
/// Backs the `get-transaction-detail` native: the reply is a string plist
119
/// carrying the transaction header plus its physical `:splits` and `:prices`,
120
/// with split/price keys symmetric to what create/update-transaction parse.
121
/// Returns `None` when the transaction was not found.
122
3
pub(super) fn render_full_transaction(
123
3
    name: &str,
124
3
    result: Result<Option<CmdResult>, CmdError>,
125
3
) -> wasmtime::Result<Option<String>> {
126
2
    match result {
127
2
        Ok(Some(CmdResult::TaggedEntities { entities, .. })) => {
128
2
            let mut tx_data = None;
129
2
            let mut splits = Vec::new();
130
2
            let mut prices = Vec::new();
131
7
            for (entity, tags) in entities {
132
7
                match entity {
133
2
                    FinanceEntity::Transaction(tx) => {
134
2
                        let note = tag_value(&tags, "note").map(str::to_string);
135
2
                        tx_data = Some((tx, note));
136
2
                    }
137
4
                    FinanceEntity::Split(s) => splits.push(s),
138
1
                    FinanceEntity::Price(p) => prices.push(p),
139
                    _ => {}
140
                }
141
            }
142
2
            let (tx, note) = match tx_data {
143
2
                Some(d) => d,
144
                None => return Ok(None),
145
            };
146
2
            let mut out = format!(
147
                "(:id \"{}\" :post-date \"{}\" :enter-date \"{}\"",
148
                tx.id,
149
2
                tx.post_date.to_rfc3339(),
150
2
                tx.enter_date.to_rfc3339(),
151
            );
152
2
            if let Some(n) = note {
153
1
                out.push_str(&format!(" :note {}", quote_string(&n)));
154
1
            }
155
2
            out.push_str(" :splits (");
156
4
            for s in &splits {
157
4
                out.push_str(&format!(
158
4
                    "(:id \"{}\" :account-id \"{}\" :commodity-id \"{}\" :value {})",
159
4
                    s.id,
160
4
                    s.account_id,
161
4
                    s.commodity_id,
162
4
                    format_ratio(s.value_num, s.value_denom),
163
4
                ));
164
4
            }
165
2
            out.push(')');
166
2
            out.push_str(" :prices (");
167
2
            for p in &prices {
168
1
                if let (Some(cs), Some(cu)) = (p.commodity_split, p.currency_split) {
169
1
                    out.push_str(&format!(
170
1
                        "(:id \"{}\" :commodity-id \"{}\" :currency-id \"{}\" \
171
1
                         :commodity-split \"{}\" :currency-split \"{}\" \
172
1
                         :value-num {} :value-denom {} :date \"{}\")",
173
1
                        p.id,
174
1
                        p.commodity_id,
175
1
                        p.currency_id,
176
1
                        cs,
177
1
                        cu,
178
1
                        p.value_num,
179
1
                        p.value_denom,
180
1
                        p.date.to_rfc3339(),
181
1
                    ));
182
1
                }
183
            }
184
2
            out.push(')');
185
2
            out.push(')');
186
2
            Ok(Some(out))
187
        }
188
1
        Ok(None) => Ok(None),
189
        Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
190
            "{name}: expected TaggedEntities, got {other:?}"
191
        ))),
192
        Err(err) => Err(wasmtime::Error::msg(format!("{name}: {err}"))),
193
    }
194
3
}
195

            
196
/// Test-only legacy renderer; production paths now ship typed
197
/// `pair<transaction>` / `EntityRef(Transaction)` via the alloc helpers.
198
/// Retained for the existing format-assertion tests until A6 collapses
199
/// the streaming-string capture protocol.
200
#[cfg(test)]
201
4
pub(super) fn format_tagged_transactions(
202
4
    entities: &[(
203
4
        FinanceEntity,
204
4
        std::collections::HashMap<String, FinanceEntity>,
205
4
    )],
206
4
    pagination: Option<&server::command::PaginationInfo>,
207
4
) -> String {
208
4
    let mut out = String::from("(:transactions (");
209
4
    for (idx, (entity, tags)) in entities.iter().enumerate() {
210
2
        if idx > 0 {
211
            out.push(' ');
212
2
        }
213
2
        match entity {
214
2
            FinanceEntity::Transaction(tx) => {
215
2
                out.push_str(&format!(
216
2
                    "(:id \"{}\" :post-date \"{}\" :enter-date \"{}\"",
217
2
                    tx.id,
218
2
                    tx.post_date.to_rfc3339(),
219
2
                    tx.enter_date.to_rfc3339()
220
2
                ));
221
2
                if let Some(note) = tag_value(tags, "note") {
222
1
                    out.push_str(&format!(" :note {}", quote_string(note)));
223
1
                }
224
2
                out.push(')');
225
            }
226
            other => {
227
                out.push_str(&format!("(:error \"unexpected entity {other:?}\")"));
228
            }
229
        }
230
    }
231
4
    out.push_str(") :pagination ");
232
4
    match pagination {
233
2
        Some(p) => out.push_str(&format!(
234
            "(:total {} :limit {} :offset {} :has-more {})",
235
            p.total_count,
236
            p.limit,
237
            p.offset,
238
2
            if p.has_more { "t" } else { "nil" }
239
        )),
240
2
        None => out.push_str("nil"),
241
    }
242
4
    out.push(')');
243
4
    out
244
4
}