1
use derive_more::From;
2
use finance::{
3
    account::Account, commodity::Commodity, error::FinanceError, price::Price, split::Split,
4
    tag::Tag, transaction::Transaction,
5
};
6
use num_rational::Rational64;
7
use serde::{Deserialize, Serialize};
8
use sqlx::{
9
    types::Uuid,
10
    types::chrono::{DateTime, Utc},
11
};
12
use std::{
13
    collections::HashMap,
14
    fmt::{self, Debug},
15
};
16
use thiserror::Error;
17

            
18
use crate::{config::ConfigError, error::ServerError};
19

            
20
pub mod account;
21
pub mod commodity;
22
pub mod config;
23
pub mod report;
24
pub mod split;
25
pub mod ssh_key;
26
pub mod transaction;
27
pub mod user;
28

            
29
#[derive(Debug, Clone)]
30
pub struct CommodityInfo {
31
    pub commodity_id: Uuid,
32
    pub symbol: String,
33
    pub name: String,
34
}
35

            
36
#[derive(Debug, Clone)]
37
pub struct PaginationInfo {
38
    pub total_count: i64,
39
    pub limit: i64,
40
    pub offset: i64,
41
    pub has_more: bool,
42
}
43

            
44
#[derive(Debug, From)]
45
pub enum FinanceEntity {
46
    Commodity(Commodity),
47
    Tag(Tag),
48
    Split(Split),
49
    Transaction(Transaction),
50
    Price(Price),
51
    Account(Account),
52
}
53

            
54
#[derive(Debug, From)]
55
pub enum Argument {
56
    String(String),
57
    Rational(Rational64),
58
    Uuid(Uuid),
59
    Data(Vec<u8>),
60
    FinanceEntity(FinanceEntity),
61
    FinanceEntities(Vec<FinanceEntity>),
62
    DateTime(DateTime<Utc>),
63
}
64

            
65
impl From<Argument> for String {
66
    fn from(arg: Argument) -> Self {
67
        match arg {
68
            Argument::String(s) => s,
69
            _ => panic!("Cannot convert {arg:?} to String"),
70
        }
71
    }
72
}
73

            
74
impl From<Argument> for Rational64 {
75
    fn from(arg: Argument) -> Self {
76
        match arg {
77
            Argument::Rational(r) => r,
78
            _ => panic!("Cannot convert {arg:?} to Rational64"),
79
        }
80
    }
81
}
82

            
83
impl From<Argument> for Vec<u8> {
84
    fn from(arg: Argument) -> Self {
85
        match arg {
86
            Argument::Data(d) => d,
87
            _ => panic!("Cannot convert {arg:?} to Vec<u8>"),
88
        }
89
    }
90
}
91

            
92
#[derive(Debug, Clone, Serialize)]
93
pub struct CommodityAmount {
94
    pub commodity_id: Uuid,
95
    pub commodity_symbol: String,
96
    pub amount: Rational64,
97
}
98

            
99
#[derive(Debug, Clone, Serialize)]
100
pub struct ReportNode {
101
    pub account_id: Uuid,
102
    pub account_name: String,
103
    pub account_path: String,
104
    pub depth: usize,
105
    pub account_type: Option<String>,
106
    pub amounts: Vec<CommodityAmount>,
107
    pub children: Vec<ReportNode>,
108
}
109

            
110
#[derive(Debug, Clone, Serialize)]
111
pub struct ReportMeta {
112
    pub date_from: Option<DateTime<Utc>>,
113
    pub date_to: Option<DateTime<Utc>>,
114
    pub target_commodity_id: Option<Uuid>,
115
}
116

            
117
#[derive(Debug, Clone, Serialize)]
118
pub struct PeriodData {
119
    pub label: Option<String>,
120
    pub roots: Vec<ReportNode>,
121
}
122

            
123
#[derive(Debug, Clone, Serialize)]
124
pub struct ReportData {
125
    pub meta: ReportMeta,
126
    pub periods: Vec<PeriodData>,
127
}
128

            
129
/// One pane of an `ActivityReport`. The caller supplies a label, a filter
130
/// that selects which splits belong in the pane, and a display-time
131
/// sign-flip hint. The command returns raw accountant values; `flip_sign`
132
/// just travels with the data so UIs know how to render it.
133
#[derive(Debug, Clone, Serialize, Deserialize)]
134
pub struct ActivityGroup {
135
    pub label: String,
136
    pub filter: ReportFilter,
137
    #[serde(default)]
138
    pub flip_sign: bool,
139
}
140

            
141
#[derive(Debug, Clone, Serialize)]
142
pub struct ActivityGroupResult {
143
    pub label: String,
144
    pub flip_sign: bool,
145
    pub roots: Vec<ReportNode>,
146
}
147

            
148
#[derive(Debug, Clone, Serialize)]
149
pub struct ActivityPeriod {
150
    pub label: Option<String>,
151
    pub groups: Vec<ActivityGroupResult>,
152
}
153

            
154
#[derive(Debug, Clone, Serialize)]
155
pub struct ActivityData {
156
    pub meta: ReportMeta,
157
    pub periods: Vec<ActivityPeriod>,
158
}
159

            
160
/// Sentinel value used in `BreakdownRow::tag_value` for splits that have no
161
/// value for the pivot tag.
162
pub const UNCATEGORIZED_KEY: &str = "__uncategorized__";
163

            
164
#[derive(Debug, Clone, Serialize)]
165
pub struct BreakdownRow {
166
    pub tag_value: String,
167
    pub is_uncategorized: bool,
168
    pub amounts: Vec<CommodityAmount>,
169
}
170

            
171
#[derive(Debug, Clone, Serialize)]
172
pub struct BreakdownPeriod {
173
    pub label: Option<String>,
174
    pub rows: Vec<BreakdownRow>,
175
}
176

            
177
#[derive(Debug, Clone, Serialize)]
178
pub struct BreakdownData {
179
    pub meta: ReportMeta,
180
    pub tag_name: String,
181
    pub periods: Vec<BreakdownPeriod>,
182
}
183

            
184
#[derive(Debug, Clone, Serialize, Deserialize)]
185
#[serde(rename_all = "lowercase")]
186
pub enum FilterEntity {
187
    Account,
188
    Transaction,
189
    Split,
190
}
191

            
192
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193
#[serde(rename_all = "lowercase")]
194
pub enum PeriodGrouping {
195
    Month,
196
    Quarter,
197
    Year,
198
}
199

            
200
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
201
#[serde(rename_all = "snake_case")]
202
pub enum BreakdownSort {
203
    #[default]
204
    AmountDesc,
205
    AmountAsc,
206
    NameAsc,
207
    NameDesc,
208
}
209

            
210
#[derive(Debug, Clone, Serialize, Deserialize)]
211
#[serde(tag = "op", content = "args", rename_all = "snake_case")]
212
pub enum ReportFilter {
213
    AccountEq(Uuid),
214
    AccountIn(Vec<Uuid>),
215
    AccountSubtree(Uuid),
216
    CounterpartyEq(Uuid),
217
    CounterpartyIn(Vec<Uuid>),
218
    CommodityEq(Uuid),
219
    CommodityIn(Vec<Uuid>),
220
    AmountGt(Rational64),
221
    AmountLt(Rational64),
222
    AmountEq(Rational64),
223
    Tag {
224
        entity: FilterEntity,
225
        name: String,
226
        value: String,
227
    },
228
    TagIn {
229
        entity: FilterEntity,
230
        name: String,
231
        values: Vec<String>,
232
    },
233
    And(Vec<ReportFilter>),
234
    Or(Vec<ReportFilter>),
235
    Not(Box<ReportFilter>),
236
}
237

            
238
#[derive(Debug, Error)]
239
pub enum CmdError {
240
    #[error("Wrong arguments: {0}")]
241
    Args(String),
242
    #[error("Config: {0}")]
243
    Config(#[from] ConfigError),
244
    #[error("Database: {0}")]
245
    DB(#[from] sqlx::Error),
246
    #[error("Server: {0}")]
247
    Server(#[from] ServerError),
248
    #[error("Finance: {0}")]
249
    Finance(#[from] FinanceError),
250
    #[error("Script execution failed: {0}")]
251
    Script(String),
252
}
253

            
254
// Implementing CmdResult as an enum with String and Rational returning options
255
#[derive(Debug)]
256
pub enum CmdResult {
257
    String(String),
258
    Rational(Rational64),
259
    Uuid(Uuid),
260
    Bool(bool),
261
    Data(Vec<u8>),
262
    Lines(Vec<String>),
263
    Entity(FinanceEntity),
264
    Entities(Vec<FinanceEntity>),
265
    TaggedEntities {
266
        entities: Vec<(FinanceEntity, HashMap<String, FinanceEntity>)>,
267
        pagination: Option<PaginationInfo>,
268
    },
269
    /// Transaction listings carry a per-row computed `amount` summary in a
270
    /// dedicated slot so it never collides with a user tag named `amount`.
271
    TaggedTransactions {
272
        entities: Vec<(
273
            FinanceEntity,
274
            HashMap<String, FinanceEntity>,
275
            Option<String>,
276
        )>,
277
        pagination: Option<PaginationInfo>,
278
    },
279
    CommodityInfoList(Vec<CommodityInfo>),
280
    MultiCurrencyBalance(Vec<(Commodity, Rational64)>),
281
    Report(ReportData),
282
    Breakdown(BreakdownData),
283
    Activity(ActivityData),
284
    SshKeys(Vec<ssh_key::SshKeyRecord>),
285
}
286

            
287
impl From<String> for CmdResult {
288
2230
    fn from(s: String) -> Self {
289
2230
        CmdResult::String(s)
290
2230
    }
291
}
292

            
293
pub struct LinesView<'view>(&'view CmdResult);
294
pub struct LinesViewMut<'view>(&'view mut CmdResult);
295

            
296
impl std::ops::Deref for LinesView<'_> {
297
    type Target = Vec<String>;
298

            
299
    fn deref(&self) -> &Self::Target {
300
        match self.0 {
301
            CmdResult::Lines(lines) => lines,
302
            _ => panic!("Attempted to use Lines view on non-Lines variant"),
303
        }
304
    }
305
}
306

            
307
impl std::ops::Deref for LinesViewMut<'_> {
308
    type Target = Vec<String>;
309

            
310
    fn deref(&self) -> &Self::Target {
311
        match self.0 {
312
            &mut CmdResult::Lines(ref lines) => lines,
313
            _ => panic!("Attempted to use Lines view on non-Lines variant"),
314
        }
315
    }
316
}
317

            
318
impl std::ops::DerefMut for LinesViewMut<'_> {
319
    fn deref_mut(&mut self) -> &mut Self::Target {
320
        match self.0 {
321
            &mut CmdResult::Lines(ref mut lines) => lines,
322
            _ => panic!("Attempted to use Lines view on non-Lines variant"),
323
        }
324
    }
325
}
326

            
327
impl CmdResult {
328
    #[must_use]
329
    pub fn as_lines(&self) -> LinesView<'_> {
330
        LinesView(self)
331
    }
332

            
333
    pub fn as_lines_mut(&mut self) -> LinesViewMut<'_> {
334
        LinesViewMut(self)
335
    }
336
}
337

            
338
impl fmt::Display for CmdResult {
339
1
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
340
1
        match self {
341
1
            CmdResult::String(s) => write!(f, "{s}"),
342
            CmdResult::Rational(r) => write!(f, "{r}"),
343
            CmdResult::Data(d) => write!(f, "CmdResult<Data>: \"{}\" bytes", d.len()),
344
            CmdResult::Lines(l) => {
345
                // Find the maximum width for alignment
346
                let max_width = l.iter().map(std::string::String::len).max().unwrap_or(0);
347

            
348
                // Write header
349
                writeln!(f, "CmdResult<Lines>: {} items", l.len())?;
350

            
351
                // Write each item in a column format
352
                for (i, item) in l.iter().enumerate() {
353
                    writeln!(f, "{:>4}. {:<width$}", i + 1, item, width = max_width)?;
354
                }
355
                Ok(())
356
            }
357
            CmdResult::Entity(e) => write!(f, "CmdResult<FinanceEntity>: \"{e:?}\""),
358
            CmdResult::Entities(e) => write!(f, "CmdResult<FinanceEntities>: \"{}\"", e.len()),
359
            CmdResult::TaggedEntities {
360
                entities,
361
                pagination,
362
            } => match pagination {
363
                Some(p) => write!(
364
                    f,
365
                    "CmdResult<TaggedEntities>: {} of {} (offset: {})",
366
                    entities.len(),
367
                    p.total_count,
368
                    p.offset
369
                ),
370
                None => write!(f, "CmdResult<TaggedEntities>: \"{}\"", entities.len()),
371
            },
372
            CmdResult::TaggedTransactions {
373
                entities,
374
                pagination,
375
            } => match pagination {
376
                Some(p) => write!(
377
                    f,
378
                    "CmdResult<TaggedTransactions>: {} of {} (offset: {})",
379
                    entities.len(),
380
                    p.total_count,
381
                    p.offset
382
                ),
383
                None => write!(f, "CmdResult<TaggedTransactions>: \"{}\"", entities.len()),
384
            },
385
            CmdResult::CommodityInfoList(e) => {
386
                write!(f, "CmdResult<CommodityInfoList>: \"{}\"", e.len())
387
            }
388
            CmdResult::MultiCurrencyBalance(e) => {
389
                write!(f, "CmdResult<MultiCurrencyBalance>: \"{}\"", e.len())
390
            }
391
            CmdResult::Report(r) => {
392
                write!(f, "CmdResult<Report>: {} periods", r.periods.len())
393
            }
394
            CmdResult::Breakdown(b) => {
395
                write!(
396
                    f,
397
                    "CmdResult<Breakdown>: tag={} periods={}",
398
                    b.tag_name,
399
                    b.periods.len()
400
                )
401
            }
402
            CmdResult::Activity(a) => {
403
                write!(
404
                    f,
405
                    "CmdResult<Activity>: {} periods, {} groups",
406
                    a.periods.len(),
407
                    a.periods.first().map_or(0, |p| p.groups.len()),
408
                )
409
            }
410
            CmdResult::Uuid(id) => write!(f, "{id}"),
411
            CmdResult::Bool(b) => write!(f, "{b}"),
412
            CmdResult::SshKeys(keys) => {
413
                write!(f, "CmdResult<SshKeys>: {} keys", keys.len())
414
            }
415
        }
416
1
    }
417
}