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
|
/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"]
|
keywords = ["personal-finance", "receipts"]
|
||||||
|
|
||||||
[dependencies]
|
[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 = { version = "0.5.1", default-features = false, features = ["json"] }
|
||||||
rocket_dyn_templates = { version = "0.2.0", features = ["tera"] }
|
rocket_dyn_templates = { version = "0.2.0", features = ["tera"] }
|
||||||
serde = { version = "1.0.218", default-features = false, features = ["derive"] }
|
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
|
||||||
|
}
|
||||||
|
}
|
177
src/main.rs
177
src/main.rs
|
@ -1,23 +1,48 @@
|
||||||
use std::collections::HashMap;
|
mod config;
|
||||||
use std::sync::LazyLock;
|
mod firefly;
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use rocket::form::Form;
|
use rocket::form::Form;
|
||||||
use rocket::fs::{FileServer, TempFile};
|
use rocket::fs::{FileServer, TempFile};
|
||||||
use rocket::http::Status;
|
use rocket::http::Status;
|
||||||
use rocket::response::Redirect;
|
use rocket::response::Redirect;
|
||||||
use rocket::serde::Serialize;
|
use rocket::State;
|
||||||
use rocket_dyn_templates::{context, Template};
|
use rocket_dyn_templates::{context, Template};
|
||||||
|
use serde::Serialize;
|
||||||
|
use tracing::{debug, error};
|
||||||
|
|
||||||
#[derive(Serialize)]
|
use config::{Config, ConfigError};
|
||||||
struct Transaction {
|
use firefly::{Firefly, TransactionRead};
|
||||||
id: u32,
|
|
||||||
amount: f64,
|
struct Context {
|
||||||
description: String,
|
#[allow(dead_code)]
|
||||||
date: String,
|
config: Config,
|
||||||
|
firefly: Firefly,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Database {
|
#[derive(Debug, thiserror::Error)]
|
||||||
transactions: HashMap<i32, Transaction>,
|
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)]
|
#[derive(rocket::FromForm)]
|
||||||
|
@ -27,19 +52,43 @@ struct TransactionPostData<'r> {
|
||||||
photo: Vec<TempFile<'r>>,
|
photo: Vec<TempFile<'r>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
static DB: LazyLock<Database> = LazyLock::new(|| {
|
impl TryFrom<TransactionRead> for Transaction {
|
||||||
let mut transactions = HashMap::new();
|
type Error = &'static str;
|
||||||
transactions.insert(
|
|
||||||
5411,
|
fn try_from(t: TransactionRead) -> Result<Self, Self::Error> {
|
||||||
Transaction {
|
let first_split = match t.attributes.transactions.first() {
|
||||||
id: 5411,
|
Some(t) => t,
|
||||||
amount: 140.38,
|
None => {
|
||||||
description: "THE HOME DEPOT #2218".into(),
|
error!("Invalid transaction {}: no splits", t.id);
|
||||||
date: "March 2nd, 2025".into(),
|
return Err("Transaction has no splits");
|
||||||
},
|
},
|
||||||
);
|
};
|
||||||
Database { transactions }
|
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("/")]
|
#[rocket::get("/")]
|
||||||
async fn index() -> Redirect {
|
async fn index() -> Redirect {
|
||||||
|
@ -47,17 +96,64 @@ async fn index() -> Redirect {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rocket::get("/transactions")]
|
#[rocket::get("/transactions")]
|
||||||
async fn transaction_list() -> Template {
|
async fn transaction_list(ctx: &State<Context>) -> (Status, Template) {
|
||||||
let transactions: Vec<_> = DB.transactions.values().collect();
|
let result = ctx
|
||||||
Template::render("transaction-list", context! {
|
.firefly
|
||||||
transactions: transactions,
|
.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>")]
|
#[rocket::get("/transactions/<id>")]
|
||||||
async fn get_transaction(id: i32) -> Option<Template> {
|
async fn get_transaction(id: &str, ctx: &State<Context>) -> Option<Template> {
|
||||||
let txn = DB.transactions.get(&id)?;
|
match ctx.firefly.get_transaction(id).await {
|
||||||
Some(Template::render("transaction", txn))
|
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>")]
|
#[rocket::post("/transactions/<id>", data = "<form>")]
|
||||||
|
@ -71,7 +167,28 @@ async fn update_transaction(
|
||||||
|
|
||||||
#[rocket::launch]
|
#[rocket::launch]
|
||||||
async fn rocket() -> _ {
|
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()
|
rocket::build()
|
||||||
|
.manage(ctx)
|
||||||
.mount(
|
.mount(
|
||||||
"/",
|
"/",
|
||||||
rocket::routes![
|
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