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
Dustin 2025-03-08 16:01:08 -06:00
parent b55fb893e2
commit 0c6f9385e6
9 changed files with 1107 additions and 34 deletions

2
.gitignore vendored
View File

@ -1 +1,3 @@
/config.toml
/firefly.token
/target

818
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -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"] }

3
firefly.env Normal file
View File

@ -0,0 +1,3 @@
DB_CONNECTION=sqlite
APP_KEY=FVo8gylkwKlgtXbn4hjcdCuekDEbGyl2
MAIL_MAILER=log

30
src/config.rs Normal file
View File

@ -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)?)
}
}

86
src/firefly.rs Normal file
View File

@ -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
}
}

View File

@ -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(),
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");
},
);
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("/")]
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![

9
start-firefly.sh Normal file
View File

@ -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

View File

@ -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 %}