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