Skip to main content

yuki_cli/cli/
upload.rs

1use std::path::Path;
2
3use base64::Engine as _;
4use base64::engine::general_purpose::STANDARD as BASE64;
5
6use crate::client::archive::ArchiveClient;
7use crate::config::Config;
8use crate::error::YukiError;
9use crate::output::{OutputFormat, format_json, format_table, is_tty};
10
11/// Options for uploading a document with invoice metadata.
12pub struct UploadOptions<'a> {
13    pub folder: &'a str,
14    pub amount: Option<f64>,
15    pub category: Option<&'a str>,
16    pub payment_method: Option<&'a str>,
17    pub project: Option<&'a str>,
18    pub remarks: Option<&'a str>,
19    pub currency: &'a str,
20}
21
22use crate::folders::folder_id;
23
24/// Upload a document to the Yuki archive.
25///
26/// When `options.amount` is provided, the richer `UploadDocumentWithData` operation is used,
27/// allowing cost category, payment method, project, and remarks to be attached.
28/// Otherwise `UploadDocument` is used.
29pub async fn run(
30    config: &Config,
31    admin: Option<&str>,
32    file: &str,
33    options: UploadOptions<'_>,
34    format: Option<&str>,
35    quiet: bool,
36) -> Result<(), YukiError> {
37    let fid = folder_id(options.folder)?;
38
39    let bytes = std::fs::read(file).map_err(|e| YukiError::Config(format!("{file}: {e}")))?;
40    let data_base64 = BASE64.encode(&bytes);
41    let filename = Path::new(file)
42        .file_name()
43        .and_then(|n| n.to_str())
44        .unwrap_or(file);
45
46    let entry = config.resolve_admin(admin)?;
47    let mut client = ArchiveClient::new();
48    client.authenticate(&config.api_key).await?;
49
50    let doc_id = match options.amount {
51        Some(amt) => {
52            client
53                .upload_document_with_data(
54                    &entry.admin_id,
55                    filename,
56                    &data_base64,
57                    fid,
58                    options.currency,
59                    amt,
60                    options.category,
61                    options.payment_method,
62                    options.project,
63                    options.remarks,
64                )
65                .await?
66        }
67        None => {
68            client
69                .upload_document(&entry.admin_id, filename, &data_base64, fid)
70                .await?
71        }
72    };
73
74    if quiet {
75        return Ok(());
76    }
77
78    let headers = vec!["Document ID".to_string()];
79    let rows = vec![vec![doc_id]];
80
81    let fmt = OutputFormat::from_flag(format, is_tty());
82    match fmt {
83        OutputFormat::Table => println!("{}", format_table(&headers, &rows)),
84        OutputFormat::Json => println!("{}", format_json(&headers, &rows)),
85    }
86
87    Ok(())
88}
89
90/// List all available cost categories.
91pub async fn categories(config: &Config, format: Option<&str>) -> Result<(), YukiError> {
92    let mut client = ArchiveClient::new();
93    client.authenticate(&config.api_key).await?;
94    let cats = client.cost_categories().await?;
95
96    let headers = vec!["ID".to_string(), "Description".to_string()];
97    let rows: Vec<Vec<String>> = cats
98        .into_iter()
99        .map(|c| vec![c.id, c.description])
100        .collect();
101
102    let fmt = OutputFormat::from_flag(format, is_tty());
103    match fmt {
104        OutputFormat::Table => println!("{}", format_table(&headers, &rows)),
105        OutputFormat::Json => println!("{}", format_json(&headers, &rows)),
106    }
107
108    Ok(())
109}
110
111/// List all available payment methods.
112pub async fn payment_methods(config: &Config, format: Option<&str>) -> Result<(), YukiError> {
113    let mut client = ArchiveClient::new();
114    client.authenticate(&config.api_key).await?;
115    let methods = client.payment_methods().await?;
116
117    let headers = vec!["ID".to_string(), "Description".to_string()];
118    let rows: Vec<Vec<String>> = methods
119        .into_iter()
120        .map(|m| vec![m.id, m.description])
121        .collect();
122
123    let fmt = OutputFormat::from_flag(format, is_tty());
124    match fmt {
125        OutputFormat::Table => println!("{}", format_table(&headers, &rows)),
126        OutputFormat::Json => println!("{}", format_json(&headers, &rows)),
127    }
128
129    Ok(())
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn folder_id_inkoop() {
138        assert_eq!(folder_id("inkoop").unwrap(), 1);
139        assert_eq!(folder_id("purchase").unwrap(), 1);
140    }
141
142    #[test]
143    fn folder_id_verkoop() {
144        assert_eq!(folder_id("verkoop").unwrap(), 2);
145        assert_eq!(folder_id("sales").unwrap(), 2);
146    }
147
148    #[test]
149    fn folder_id_uitzoeken_default() {
150        assert_eq!(folder_id("uitzoeken").unwrap(), 7);
151    }
152
153    #[test]
154    fn folder_id_all_known() {
155        assert_eq!(folder_id("bank").unwrap(), 3);
156        assert_eq!(folder_id("personeel").unwrap(), 4);
157        assert_eq!(folder_id("personnel").unwrap(), 4);
158        assert_eq!(folder_id("belasting").unwrap(), 5);
159        assert_eq!(folder_id("tax").unwrap(), 5);
160        assert_eq!(folder_id("overig-financieel").unwrap(), 8);
161        assert_eq!(folder_id("other").unwrap(), 8);
162    }
163
164    #[test]
165    fn folder_id_unknown_errors() {
166        assert!(folder_id("nonexistent").is_err());
167    }
168}