Fetch transactions from Firefly III
This is all pretty straightforward. The only real problem is that the search results only contain matching transactions *splits*. Since transactions themselves do not have an amount, the value shown in the _Amount_ column on the transaction list may be incorrect if a transaction contains multiple splits and some of them do not match the search query.bugfix/ci-buildah
parent
b55fb893e2
commit
0c6f9385e6
|
@ -1 +1,3 @@
|
|||
/config.toml
|
||||
/firefly.token
|
||||
/target
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -10,6 +10,12 @@ license = "MIT OR Apache-2.0"
|
|||
keywords = ["personal-finance", "receipts"]
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4.40", default-features = false, features = ["serde"] }
|
||||
reqwest = { version = "0.12.12", features = ["json"] }
|
||||
rocket = { version = "0.5.1", default-features = false, features = ["json"] }
|
||||
rocket_dyn_templates = { version = "0.2.0", features = ["tera"] }
|
||||
serde = { version = "1.0.218", default-features = false, features = ["derive"] }
|
||||
thiserror = "2.0.12"
|
||||
toml = "0.8.20"
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
DB_CONNECTION=sqlite
|
||||
APP_KEY=FVo8gylkwKlgtXbn4hjcdCuekDEbGyl2
|
||||
MAIL_MAILER=log
|
|
@ -0,0 +1,30 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("Unable to load file: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Error parsing file: {0}")]
|
||||
Toml(#[from] toml::de::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FireflyConfig {
|
||||
pub url: String,
|
||||
pub token: PathBuf,
|
||||
pub search_query: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Config {
|
||||
pub firefly: FireflyConfig,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
|
||||
let data = std::fs::read_to_string(path)?;
|
||||
Ok(toml::from_str(&data)?)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub struct Firefly {
|
||||
url: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TransactionSplit {
|
||||
pub date: chrono::DateTime<chrono::FixedOffset>,
|
||||
pub amount: String,
|
||||
pub description: 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,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
181
src/main.rs
181
src/main.rs
|
@ -1,23 +1,48 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
mod config;
|
||||
mod firefly;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rocket::form::Form;
|
||||
use rocket::fs::{FileServer, TempFile};
|
||||
use rocket::http::Status;
|
||||
use rocket::response::Redirect;
|
||||
use rocket::serde::Serialize;
|
||||
use rocket::State;
|
||||
use rocket_dyn_templates::{context, Template};
|
||||
use serde::Serialize;
|
||||
use tracing::{debug, error};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Transaction {
|
||||
id: u32,
|
||||
amount: f64,
|
||||
description: String,
|
||||
date: String,
|
||||
use config::{Config, ConfigError};
|
||||
use firefly::{Firefly, TransactionRead};
|
||||
|
||||
struct Context {
|
||||
#[allow(dead_code)]
|
||||
config: Config,
|
||||
firefly: Firefly,
|
||||
}
|
||||
|
||||
struct Database {
|
||||
transactions: HashMap<i32, Transaction>,
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum InitError {
|
||||
#[error("Invalid config: {0}")]
|
||||
Config(#[from] ConfigError),
|
||||
}
|
||||
|
||||
impl Context {
|
||||
pub fn init(config: impl AsRef<Path>) -> Result<Self, InitError> {
|
||||
let config = Config::from_file(config)?;
|
||||
let token = std::fs::read_to_string(&config.firefly.token)
|
||||
.map_err(ConfigError::from)?;
|
||||
let firefly = Firefly::new(&config.firefly.url, &token);
|
||||
Ok(Self { config, firefly })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Transaction {
|
||||
pub id: String,
|
||||
pub amount: f64,
|
||||
pub description: String,
|
||||
pub date: String,
|
||||
}
|
||||
|
||||
#[derive(rocket::FromForm)]
|
||||
|
@ -27,19 +52,43 @@ struct TransactionPostData<'r> {
|
|||
photo: Vec<TempFile<'r>>,
|
||||
}
|
||||
|
||||
static DB: LazyLock<Database> = LazyLock::new(|| {
|
||||
let mut transactions = HashMap::new();
|
||||
transactions.insert(
|
||||
5411,
|
||||
Transaction {
|
||||
id: 5411,
|
||||
amount: 140.38,
|
||||
description: "THE HOME DEPOT #2218".into(),
|
||||
date: "March 2nd, 2025".into(),
|
||||
},
|
||||
);
|
||||
Database { transactions }
|
||||
});
|
||||
impl TryFrom<TransactionRead> for Transaction {
|
||||
type Error = &'static str;
|
||||
|
||||
fn try_from(t: TransactionRead) -> Result<Self, Self::Error> {
|
||||
let first_split = match t.attributes.transactions.first() {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
error!("Invalid transaction {}: no splits", t.id);
|
||||
return Err("Transaction has no splits");
|
||||
},
|
||||
};
|
||||
let date = first_split.date;
|
||||
let amount = t
|
||||
.attributes
|
||||
.transactions
|
||||
.iter()
|
||||
.filter_map(|t| match t.amount.parse::<f64>() {
|
||||
Ok(v) => Some(v),
|
||||
Err(e) => {
|
||||
error!("Invalid amount: {}", e);
|
||||
None
|
||||
},
|
||||
})
|
||||
.sum();
|
||||
let description = if let Some(title) = &t.attributes.group_title {
|
||||
title.into()
|
||||
} else {
|
||||
first_split.description.clone()
|
||||
};
|
||||
Ok(Self {
|
||||
id: t.id,
|
||||
amount,
|
||||
description,
|
||||
date: date.format("%A, %_d %B %Y").to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::get("/")]
|
||||
async fn index() -> Redirect {
|
||||
|
@ -47,17 +96,64 @@ async fn index() -> Redirect {
|
|||
}
|
||||
|
||||
#[rocket::get("/transactions")]
|
||||
async fn transaction_list() -> Template {
|
||||
let transactions: Vec<_> = DB.transactions.values().collect();
|
||||
Template::render("transaction-list", context! {
|
||||
transactions: transactions,
|
||||
})
|
||||
async fn transaction_list(ctx: &State<Context>) -> (Status, Template) {
|
||||
let result = ctx
|
||||
.firefly
|
||||
.search_transactions(&ctx.config.firefly.search_query)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(r) => {
|
||||
let transactions: Vec<_> = r
|
||||
.data
|
||||
.into_iter()
|
||||
.filter_map(|t| match Transaction::try_from(t) {
|
||||
Ok(t) => Some(t),
|
||||
Err(e) => {
|
||||
error!("Error parsing transaction details: {}", e);
|
||||
None
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
(
|
||||
Status::Ok,
|
||||
Template::render(
|
||||
"transaction-list",
|
||||
context! {
|
||||
transactions: transactions,
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Error fetching transaction list: {}", e);
|
||||
(
|
||||
Status::InternalServerError,
|
||||
Template::render(
|
||||
"error",
|
||||
context! {
|
||||
error: "Failed to fetch transaction list from Firefly III",
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::get("/transactions/<id>")]
|
||||
async fn get_transaction(id: i32) -> Option<Template> {
|
||||
let txn = DB.transactions.get(&id)?;
|
||||
Some(Template::render("transaction", txn))
|
||||
async fn get_transaction(id: &str, ctx: &State<Context>) -> Option<Template> {
|
||||
match ctx.firefly.get_transaction(id).await {
|
||||
Ok(t) => match Transaction::try_from(t.data) {
|
||||
Ok(t) => return Some(Template::render("transaction", t)),
|
||||
Err(e) => {
|
||||
error!("Invalid transaction {}: {}", id, e);
|
||||
},
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to get transaction {}: {}", id, e);
|
||||
},
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[rocket::post("/transactions/<id>", data = "<form>")]
|
||||
|
@ -71,7 +167,28 @@ async fn update_transaction(
|
|||
|
||||
#[rocket::launch]
|
||||
async fn rocket() -> _ {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
|
||||
let cfg_path = match std::env::args_os().nth(1) {
|
||||
Some(f) => PathBuf::from(f),
|
||||
None => PathBuf::from("config.toml"),
|
||||
};
|
||||
debug!("Using configuration file {}", cfg_path.display());
|
||||
|
||||
let ctx = match Context::init(cfg_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("Failed to initialize application context: {}", e);
|
||||
std::process::exit(1);
|
||||
},
|
||||
};
|
||||
debug!("Using Firefly III URL {}", &ctx.firefly.url());
|
||||
|
||||
rocket::build()
|
||||
.manage(ctx)
|
||||
.mount(
|
||||
"/",
|
||||
rocket::routes![
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
#!/bin/sh
|
||||
podman run \
|
||||
--rm \
|
||||
-d \
|
||||
--name firefly-iii \
|
||||
--env-file firefly.env \
|
||||
-v firefly-iii:/storage \
|
||||
-p 8080:8080 \
|
||||
docker.io/fireflyiii/core
|
|
@ -0,0 +1,6 @@
|
|||
{% extends "base" %} {% block head %}
|
||||
<title>Error</title>
|
||||
{% endblock %} {% block main %}
|
||||
<h1>Error</h1>
|
||||
<p style="color: var(--sl-color-red-600)">{{ error }}</p>
|
||||
{% endblock %}
|
Loading…
Reference in New Issue