receipts/src/firefly.rs

186 lines
4.7 KiB
Rust

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<chrono::FixedOffset>,
pub amount: String,
pub description: String,
pub notes: Option<String>,
}
#[derive(Deserialize)]
pub struct TransactionAttributes {
pub group_title: Option<String>,
pub transactions: Vec<TransactionSplit>,
}
#[derive(Deserialize)]
pub struct TransactionRead {
pub id: String,
pub attributes: TransactionAttributes,
}
#[derive(Deserialize)]
pub struct TransactionArray {
pub data: Vec<TransactionRead>,
}
#[derive(Deserialize)]
pub struct TransactionSingle {
pub data: TransactionRead,
}
#[derive(Serialize)]
pub struct TransactionSplitUpdate {
pub transaction_journal_id: String,
pub amount: String,
pub notes: Option<String>,
}
#[derive(Serialize)]
pub struct TransactionUpdate {
pub transactions: Vec<TransactionSplitUpdate>,
}
#[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::<f64>() {
Ok(v) => Some(v),
Err(e) => {
error!("Invalid amount: {}", e);
None
},
})
.sum()
}
}
impl Firefly {
pub fn new(url: impl Into<String>, 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<TransactionArray, reqwest::Error> {
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<TransactionSingle, reqwest::Error> {
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<TransactionSingle, reqwest::Error> {
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<reqwest::Body>,
filename: impl Into<String>,
) -> 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(())
}
}