Skip to main content

server/command/
mod.rs

1use derive_more::From;
2use finance::{
3    account::Account, commodity::Commodity, error::FinanceError, price::Price, split::Split,
4    tag::Tag, transaction::Transaction,
5};
6use num_rational::Rational64;
7use serde::{Deserialize, Serialize};
8use sqlx::{
9    types::Uuid,
10    types::chrono::{DateTime, Utc},
11};
12use std::{
13    collections::HashMap,
14    fmt::{self, Debug},
15};
16use thiserror::Error;
17
18use crate::{config::ConfigError, error::ServerError};
19
20pub mod account;
21pub mod commodity;
22pub mod config;
23pub mod report;
24pub mod split;
25pub mod ssh_key;
26pub mod transaction;
27pub mod user;
28
29#[derive(Debug, Clone)]
30pub struct CommodityInfo {
31    pub commodity_id: Uuid,
32    pub symbol: String,
33    pub name: String,
34}
35
36#[derive(Debug, Clone)]
37pub 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)]
45pub 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)]
55pub 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
65impl 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
74impl 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
83impl 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)]
93pub struct CommodityAmount {
94    pub commodity_id: Uuid,
95    pub commodity_symbol: String,
96    pub amount: Rational64,
97}
98
99#[derive(Debug, Clone, Serialize)]
100pub 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)]
111pub 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)]
118pub struct PeriodData {
119    pub label: Option<String>,
120    pub roots: Vec<ReportNode>,
121}
122
123#[derive(Debug, Clone, Serialize)]
124pub 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)]
134pub 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)]
142pub struct ActivityGroupResult {
143    pub label: String,
144    pub flip_sign: bool,
145    pub roots: Vec<ReportNode>,
146}
147
148#[derive(Debug, Clone, Serialize)]
149pub struct ActivityPeriod {
150    pub label: Option<String>,
151    pub groups: Vec<ActivityGroupResult>,
152}
153
154#[derive(Debug, Clone, Serialize)]
155pub 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.
162pub const UNCATEGORIZED_KEY: &str = "__uncategorized__";
163
164#[derive(Debug, Clone, Serialize)]
165pub struct BreakdownRow {
166    pub tag_value: String,
167    pub is_uncategorized: bool,
168    pub amounts: Vec<CommodityAmount>,
169}
170
171#[derive(Debug, Clone, Serialize)]
172pub struct BreakdownPeriod {
173    pub label: Option<String>,
174    pub rows: Vec<BreakdownRow>,
175}
176
177#[derive(Debug, Clone, Serialize)]
178pub 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")]
186pub enum FilterEntity {
187    Account,
188    Transaction,
189    Split,
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "lowercase")]
194pub 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")]
202pub 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")]
212pub 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)]
239pub 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)]
256pub 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    CommodityInfoList(Vec<CommodityInfo>),
270    MultiCurrencyBalance(Vec<(Commodity, Rational64)>),
271    Report(ReportData),
272    Breakdown(BreakdownData),
273    Activity(ActivityData),
274    SshKeys(Vec<ssh_key::SshKeyRecord>),
275}
276
277impl From<String> for CmdResult {
278    fn from(s: String) -> Self {
279        CmdResult::String(s)
280    }
281}
282
283pub struct LinesView<'view>(&'view CmdResult);
284pub struct LinesViewMut<'view>(&'view mut CmdResult);
285
286impl std::ops::Deref for LinesView<'_> {
287    type Target = Vec<String>;
288
289    fn deref(&self) -> &Self::Target {
290        match self.0 {
291            CmdResult::Lines(lines) => lines,
292            _ => panic!("Attempted to use Lines view on non-Lines variant"),
293        }
294    }
295}
296
297impl std::ops::Deref for LinesViewMut<'_> {
298    type Target = Vec<String>;
299
300    fn deref(&self) -> &Self::Target {
301        match self.0 {
302            &mut CmdResult::Lines(ref lines) => lines,
303            _ => panic!("Attempted to use Lines view on non-Lines variant"),
304        }
305    }
306}
307
308impl std::ops::DerefMut for LinesViewMut<'_> {
309    fn deref_mut(&mut self) -> &mut Self::Target {
310        match self.0 {
311            &mut CmdResult::Lines(ref mut lines) => lines,
312            _ => panic!("Attempted to use Lines view on non-Lines variant"),
313        }
314    }
315}
316
317impl CmdResult {
318    #[must_use]
319    pub fn as_lines(&self) -> LinesView<'_> {
320        LinesView(self)
321    }
322
323    pub fn as_lines_mut(&mut self) -> LinesViewMut<'_> {
324        LinesViewMut(self)
325    }
326}
327
328impl fmt::Display for CmdResult {
329    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
330        match self {
331            CmdResult::String(s) => write!(f, "{s}"),
332            CmdResult::Rational(r) => write!(f, "{r}"),
333            CmdResult::Data(d) => write!(f, "CmdResult<Data>: \"{}\" bytes", d.len()),
334            CmdResult::Lines(l) => {
335                // Find the maximum width for alignment
336                let max_width = l.iter().map(std::string::String::len).max().unwrap_or(0);
337
338                // Write header
339                writeln!(f, "CmdResult<Lines>: {} items", l.len())?;
340
341                // Write each item in a column format
342                for (i, item) in l.iter().enumerate() {
343                    writeln!(f, "{:>4}. {:<width$}", i + 1, item, width = max_width)?;
344                }
345                Ok(())
346            }
347            CmdResult::Entity(e) => write!(f, "CmdResult<FinanceEntity>: \"{e:?}\""),
348            CmdResult::Entities(e) => write!(f, "CmdResult<FinanceEntities>: \"{}\"", e.len()),
349            CmdResult::TaggedEntities {
350                entities,
351                pagination,
352            } => match pagination {
353                Some(p) => write!(
354                    f,
355                    "CmdResult<TaggedEntities>: {} of {} (offset: {})",
356                    entities.len(),
357                    p.total_count,
358                    p.offset
359                ),
360                None => write!(f, "CmdResult<TaggedEntities>: \"{}\"", entities.len()),
361            },
362            CmdResult::CommodityInfoList(e) => {
363                write!(f, "CmdResult<CommodityInfoList>: \"{}\"", e.len())
364            }
365            CmdResult::MultiCurrencyBalance(e) => {
366                write!(f, "CmdResult<MultiCurrencyBalance>: \"{}\"", e.len())
367            }
368            CmdResult::Report(r) => {
369                write!(f, "CmdResult<Report>: {} periods", r.periods.len())
370            }
371            CmdResult::Breakdown(b) => {
372                write!(
373                    f,
374                    "CmdResult<Breakdown>: tag={} periods={}",
375                    b.tag_name,
376                    b.periods.len()
377                )
378            }
379            CmdResult::Activity(a) => {
380                write!(
381                    f,
382                    "CmdResult<Activity>: {} periods, {} groups",
383                    a.periods.len(),
384                    a.periods.first().map_or(0, |p| p.groups.len()),
385                )
386            }
387            CmdResult::Uuid(id) => write!(f, "{id}"),
388            CmdResult::Bool(b) => write!(f, "{b}"),
389            CmdResult::SshKeys(keys) => {
390                write!(f, "CmdResult<SshKeys>: {} keys", keys.len())
391            }
392        }
393    }
394}