1
//! Shared name-resolution and entity-builder helpers for logical transaction natives.
2

            
3
use chrono::{DateTime, Utc};
4
use finance::price::Price;
5
use finance::split::Split;
6
use finance::tag::Tag;
7
use server::command::account::ListAccounts;
8
use server::command::commodity::ListCommodities;
9
use server::command::{CmdError, CmdResult, FinanceEntity};
10
use server::logical::{LogicalSplit, PhysicalSplit, PriceRow};
11
use uuid::Uuid;
12

            
13
use super::parse::LogicalSplitInput;
14

            
15
/// Resolve a logical split's named accounts and commodities to UUIDs.
16
143
pub(super) async fn resolve_logical_split(
17
143
    ctx: &str,
18
143
    user_id: Uuid,
19
143
    s: LogicalSplitInput,
20
143
) -> wasmtime::Result<LogicalSplit> {
21
143
    let from = resolve_account(ctx, user_id, &s.from).await?;
22
138
    let to = resolve_account(ctx, user_id, &s.to).await?;
23
138
    let from_commodity = resolve_commodity(ctx, user_id, &s.from_commodity).await?;
24
137
    let to_commodity = resolve_commodity(ctx, user_id, &s.to_commodity).await?;
25
137
    Ok(LogicalSplit {
26
137
        from,
27
137
        to,
28
137
        from_commodity,
29
137
        to_commodity,
30
137
        value: s.value,
31
137
        to_amount: s.to_amount,
32
137
    })
33
143
}
34

            
35
/// Resolve an account identifier (UUID string or name tag) to a UUID.
36
///
37
/// Lists all user-scoped accounts and matches against ID string or `name` tag.
38
/// Returns an error when the key is unknown or (for name keys) ambiguous.
39
281
async fn resolve_account(ctx: &str, user_id: Uuid, key: &str) -> wasmtime::Result<Uuid> {
40
281
    let result = ListAccounts::new().user_id(user_id).run().await;
41
281
    let mut matches = extract_named_account_ids(ctx, result)?
42
281
        .into_iter()
43
669
        .filter(|(id, name)| id.to_string() == key || name.as_deref() == Some(key))
44
281
        .map(|(id, _)| id);
45
281
    let first = matches.next();
46
281
    if first.is_some() && matches.next().is_some() {
47
2
        let total = 2 + matches.count();
48
2
        return Err(wasmtime::Error::msg(format!(
49
2
            "{ctx}: ambiguous account name '{key}' ({total} matches)"
50
2
        )));
51
279
    }
52
279
    first.ok_or_else(|| wasmtime::Error::msg(format!("{ctx}: unknown account '{key}'")))
53
281
}
54

            
55
281
fn extract_named_account_ids(
56
281
    ctx: &str,
57
281
    result: Result<Option<CmdResult>, CmdError>,
58
281
) -> wasmtime::Result<Vec<(Uuid, Option<String>)>> {
59
281
    match result {
60
281
        Ok(Some(CmdResult::TaggedEntities { entities, .. })) => Ok(entities
61
281
            .into_iter()
62
669
            .filter_map(|(entity, tags)| match entity {
63
669
                FinanceEntity::Account(a) => {
64
669
                    let name = tags.get("name").and_then(|t| match t {
65
669
                        FinanceEntity::Tag(Tag { tag_value, .. }) => Some(tag_value.clone()),
66
                        _ => None,
67
669
                    });
68
669
                    Some((a.id, name))
69
                }
70
                _ => None,
71
669
            })
72
281
            .collect()),
73
        Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
74
            "{ctx}: unexpected account result {other:?}"
75
        ))),
76
        Ok(None) => Ok(Vec::new()),
77
        Err(err) => Err(wasmtime::Error::msg(format!(
78
            "{ctx}: account lookup failed: {err}"
79
        ))),
80
    }
81
281
}
82

            
83
/// Resolve a commodity identifier (UUID string or symbol) to a UUID.
84
///
85
/// Lists all user-scoped commodities and matches against ID string or `symbol`
86
/// tag (case-insensitive). Returns an error when unknown or ambiguous.
87
275
async fn resolve_commodity(ctx: &str, user_id: Uuid, key: &str) -> wasmtime::Result<Uuid> {
88
275
    let result = ListCommodities::new().user_id(user_id).run().await;
89
275
    let mut matches = extract_commodity_symbols(ctx, result)?
90
275
        .into_iter()
91
284
        .filter(|(id, sym)| {
92
284
            id == key || sym.as_deref().is_some_and(|s| s.eq_ignore_ascii_case(key))
93
284
        })
94
275
        .map(|(id, _)| id);
95
275
    let first = matches.next();
96
275
    if first.is_some() && matches.next().is_some() {
97
        return Err(wasmtime::Error::msg(format!(
98
            "{ctx}: symbol '{key}' is ambiguous; reference by uuid"
99
        )));
100
275
    }
101
274
    let id_str =
102
275
        first.ok_or_else(|| wasmtime::Error::msg(format!("{ctx}: unknown commodity '{key}'")))?;
103
274
    Uuid::parse_str(&id_str)
104
274
        .map_err(|e| wasmtime::Error::msg(format!("{ctx}: commodity uuid parse error: {e}")))
105
275
}
106

            
107
275
fn extract_commodity_symbols(
108
275
    ctx: &str,
109
275
    result: Result<Option<CmdResult>, CmdError>,
110
275
) -> wasmtime::Result<Vec<(String, Option<String>)>> {
111
275
    match result {
112
275
        Ok(Some(CmdResult::TaggedEntities { entities, .. })) => Ok(entities
113
275
            .into_iter()
114
284
            .filter_map(|(entity, tags)| match entity {
115
284
                FinanceEntity::Commodity(c) => {
116
284
                    let sym = tags.get("symbol").and_then(|t| match t {
117
284
                        FinanceEntity::Tag(Tag { tag_value, .. }) => Some(tag_value.clone()),
118
                        _ => None,
119
284
                    });
120
284
                    Some((c.id.to_string(), sym))
121
                }
122
                _ => None,
123
284
            })
124
275
            .collect()),
125
        Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
126
            "{ctx}: unexpected commodity result {other:?}"
127
        ))),
128
        Ok(None) => Ok(Vec::new()),
129
        Err(err) => Err(wasmtime::Error::msg(format!(
130
            "{ctx}: commodity lookup failed: {err}"
131
        ))),
132
    }
133
275
}
134

            
135
/// Convert physical splits to `FinanceEntity::Split` with the given transaction ID.
136
136
pub(super) fn physical_splits_to_entities(
137
136
    splits: Vec<PhysicalSplit>,
138
136
    tx_id: Uuid,
139
136
) -> Vec<FinanceEntity> {
140
136
    splits
141
136
        .into_iter()
142
272
        .map(|ps| {
143
272
            FinanceEntity::Split(Split {
144
272
                id: ps.id,
145
272
                tx_id,
146
272
                account_id: ps.account_id,
147
272
                commodity_id: ps.commodity_id,
148
272
                value_num: *ps.value.numer(),
149
272
                value_denom: *ps.value.denom(),
150
272
                reconcile_state: None,
151
272
                reconcile_date: None,
152
272
                lot_id: None,
153
272
            })
154
272
        })
155
136
        .collect()
156
136
}
157

            
158
/// Convert price rows to `FinanceEntity::Price`, using `post_date` as the
159
/// fallback date when the row carries none.
160
4
pub(super) fn price_rows_to_entities(
161
4
    price_rows: Vec<PriceRow>,
162
4
    post_date: DateTime<Utc>,
163
4
) -> Vec<FinanceEntity> {
164
4
    price_rows
165
4
        .into_iter()
166
4
        .map(|pr| {
167
4
            FinanceEntity::Price(Price {
168
4
                id: Uuid::new_v4(),
169
4
                date: pr.date.unwrap_or(post_date),
170
4
                commodity_id: pr.commodity_id,
171
4
                currency_id: pr.currency_id,
172
4
                commodity_split: Some(pr.commodity_split),
173
4
                currency_split: Some(pr.currency_split),
174
4
                value_num: pr.value_num,
175
4
                value_denom: pr.value_denom,
176
4
            })
177
4
        })
178
4
        .collect()
179
4
}