use reqwest::header::{HeaderMap, HeaderValue}; use reqwest::Client; use serde::{Deserialize, Serialize}; use tracing::{error, info}; pub struct Firefly { url: String, client: reqwest::Client, } #[derive(Deserialize)] pub struct TransactionSplit { pub transaction_journal_id: String, pub date: chrono::DateTime, pub amount: String, pub description: String, pub notes: Option, } #[derive(Deserialize)] pub struct TransactionAttributes { pub group_title: Option, pub transactions: Vec, } #[derive(Deserialize)] pub struct TransactionRead { pub id: String, pub attributes: TransactionAttributes, } #[derive(Deserialize)] pub struct TransactionArray { pub data: Vec, } #[derive(Deserialize)] pub struct TransactionSingle { pub data: TransactionRead, } #[derive(Serialize)] pub struct TransactionSplitUpdate { pub transaction_journal_id: String, pub amount: String, pub notes: Option, } #[derive(Serialize)] pub struct TransactionUpdate { pub transactions: Vec, } #[derive(Serialize)] #[non_exhaustive] enum AttachableType { TransactionJournal, } #[derive(Serialize)] struct AttachmentStore { filename: String, attachable_type: AttachableType, attachable_id: String, } #[derive(Deserialize)] struct AttachmentRead { id: String, } #[derive(Deserialize)] struct AttachmentSingle { data: AttachmentRead, } impl TransactionRead { pub fn amount(&self) -> f64 { self.attributes .transactions .iter() .filter_map(|t| match t.amount.parse::() { Ok(v) => Some(v), Err(e) => { error!("Invalid amount: {}", e); None }, }) .sum() } } impl Firefly { pub fn new(url: impl Into, token: &str) -> Self { let mut headers = HeaderMap::new(); headers.insert( "Accept", HeaderValue::from_static("application/vnd.api+json"), ); headers.insert( "Authorization", HeaderValue::from_str(&format!("Bearer {}", &token)).unwrap(), ); let client = Client::builder().default_headers(headers).build().unwrap(); Self { url: url.into().trim_end_matches("/").into(), client, } } pub fn url(&self) -> &str { &self.url } pub async fn search_transactions( &self, query: &str, ) -> Result { let url = format!("{}/api/v1/search/transactions", self.url); let res = self .client .get(url) .query(&[("query", query)]) .send() .await?; res.error_for_status_ref()?; res.json().await } pub async fn get_transaction( &self, id: &str, ) -> Result { let url = format!("{}/api/v1/transactions/{}", self.url, id); let res = self.client.get(url).send().await?; res.error_for_status_ref()?; res.json().await } pub async fn update_transaction( &self, id: &str, txn: &TransactionUpdate, ) -> Result { let url = format!("{}/api/v1/transactions/{}", self.url, id); let res = self.client.put(url).json(txn).send().await?; res.error_for_status_ref()?; info!("Successfully updated transaction {}", id); res.json().await } pub async fn attach_receipt( &self, id: &str, content: impl Into, filename: impl Into, ) -> Result<(), reqwest::Error> { let filename = filename.into(); info!("Attaching receipt {} to transaction {}", filename, id); let url = format!("{}/api/v1/attachments", self.url); let data = AttachmentStore { filename, attachable_type: AttachableType::TransactionJournal, attachable_id: id.into(), }; let res = self.client.post(url).json(&data).send().await?; res.error_for_status_ref()?; let attachment: AttachmentSingle = res.json().await?; info!("Created attachment {}", attachment.data.id); let url = format!( "{}/api/v1/attachments/{}/upload", self.url, attachment.data.id ); let res = self .client .post(url) .header("Content-Type", "application/octet-stream") .body(content) .send() .await?; res.error_for_status_ref()?; Ok(()) } }